🔥

[Flutter,Riverpod,Firebase]MVCを意識してサインアップ機能とログイン機能を実装してみよう

2024/03/22に公開

よろしくお願いします!

MVCとは


Image Credit: FlutterDevs

  • Model(データ管理): アプリケーションのデータとビジネスロジックを担当。データの保存、管理、操作を行います。
  • View(UI): ユーザーに情報を表示し、ユーザーの入力を受け取るインターフェースです。見た目(UI)に関わります。
  • Controller(ロジック制御): モデルとビューを結びつけ、ユーザーの入力に基づいてモデルを更新し、その結果をビューに反映させます。

MVCのメリット

  • 分離と専門化: MVCは責任の明確な分離を提供します。これにより、デザイナーはビューに、開発者はモデルとコントローラーに集中できます。
  • 再利用性と拡張性: 各コンポーネントは独立しているため、アプリケーションの一部を変更しても他の部分に影響を与えにくいです。これにより、アプリケーションの再利用性と拡張性が向上します。
  • 保守性: コードの構造化が促進されるため、アプリケーションの保守が容易になります。

今回のデモアプリ

今回はfirebaseを使ってメールアドレスとパスワードで、サインアップ機能(新規ユーザー登録)ログイン機能(既存のユーザーを認証) を実装していきたいと思います。

pubspec.yaml
pubspec.yaml
  cupertino_icons: ^1.0.6
  firebase_core:
  firebase_auth:
  cloud_firestore:
  firebase_database:
  firebase_storage:
  flutter_svg: ^2.0.10+1 //画像の表示ため
  flutter_riverpod: ^2.4.10
  freezed: ^2.4.7
  freezed_annotation: ^2.4.1
  image_picker: ^1.0.7
  carousel_slider: ^4.2.1


dev_dependencies:
  build_runner: ^2.4.8
  flutter_test:
    sdk: flutter
  json_serializable: ^6.7.1
  1. sinup_viewでユーザーをauthticationとfirestoreに登録

  2. login_viewでユーザーを認証する

  3. home_viewに画面遷移したら成功!

UIの流れ
sinup_viewでユーザーをauthとfirestoreに登録
👇
👇
login_viewでユーザーを認証
👇
👇
home_viewに画面遷移したら成功!⭕️

フォルダ構成

lib/
  core/
    providers.dart
  features/
    auth/
      controller/
        auth_controller.dart
      view/
        login_view.dart
        signup_view.dart
      widgets/
        auth_field.dart
    home/
      view/
        home_view.dart
  firebase/
    auth_api.dart
    user_api.dart
  models/
    user_model.dart
    user_model.freezed.dart
    user_model.g.dart
  main.dart

core/

  • providers.dart:アプリ全体で使用するRiverpodプロバイダーなどのコアとなる依存性を管理します。コア機能やサービスの設定を含みます。

features/

  • auth/:認証機能に関連するファイルを格納します。

    • controller/auth_controller.dart:認証プロセスのビジネスロジックを担当します。モデルとビュー間の仲介役として機能します。
    • view/:UIコンポーネントであるログインビューとサインアップビューを含みます。ユーザーに情報を表示し、ユーザー入力を受け取ります。
    • widgets/auth_field.dart:認証ビューで再利用されるカスタムウィジェット(例えば、カスタマイズされたテキストフィールド)を含みます。
  • home/:ホーム画面に関連するファイルを格納します。

    • view/home_view.dart:アプリのホーム画面のUIを定義します。

firebase/

  • firebase/:アプリケーションのFirebase関連の機能を管理します。このフォルダは、Firebase AuthenticationとFirestoreデータベースとの直接的なインタラクションを担うクラスを含み、アプリケーション内で必要とされる認証とデータ管理の機能を提供します。

  • auth_api.dart: Firebase Authenticationサービスとのインタラクションを担います。新規ユーザーのサインアップ、既存ユーザーのログイン、および現在のユーザー情報の取得など、認証に関連するビジネスロジックを管理します。

  • user_api.dart: Firestoreデータベースとのインタラクションを担います。ユーザーデータの保存と取得に関わる操作を管理し、アプリケーションでのユーザー情報の永続化を支援します。

models/

  • user_model.dartuser_model.freezed.dartuser_model.g.dart:アプリケーションのモデル層で、データ構造とビジネスロジック(特にデータの扱いや変換)を定義します。freezedとg.dartは、コード生成による部分で、Dartのデータモデリングや状態管理を効率化します。

Model(models/)

MVCの[M]
Modelはアプリケーションのデータ管理とビジネスロジックを担うMVCアーキテクチャの核心部分です。Model層はUI(View)やユーザー操作(Controller)から独立しており、データの永続化、更新、取得を司ります。また、Firebaseとの通信を通じて、データのリモート管理を行います。

user_model.dartの概要

UserModelの役割と構造
UserModelは、Firestoreに保存されるデータの構造を定義しています。

コード解説

user_model.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'user_model.freezed.dart';
part 'user_model.g.dart';


class UserModel with _$UserModel {
  const factory UserModel({
    required String email,
    required String name,
    (includeToJson: false) ("") String uid, // toJsonの際に除外
  }) = _UserModel;

  factory UserModel.fromJson(Map<String, dynamic> json) =>
      _$UserModelFromJson(json);
}

このコードでは、freezedパッケージを使用し、@freezedアノテーションで、コードを自動生成しています。
@JsonKey(includeToJson: false)アノテーションを使ってuidフィールドをJSONシリアライズの対象から除外しています。
これは、Firebaseでは、一意のユーザーID(uid)がデータベースのドキュメントIDとして使われるため、データ本体に含める必要がなく、セキュリティ上も外部に公開すべきではないからです。

ポイントのまとめ

  • データモデルの役割: UserModelはアプリケーションでユーザー情報を管理するための中心的なデータ構造です。
  • uidの取り扱い: Firebaseでのユーザー識別子(uid)はデータモデルには含まれますが、セキュリティとデータベース設計の観点から、JSONへのシリアライズ時には除外されます。
  • コード自動生成の活用: freezedパッケージを利用することで、データモデルの定義を簡潔に保ちつつ、開発効率とコードの品質を向上させます。

auth_api.dartの概要

Firebase Authenticationを利用してユーザー認証機能(サインアップ、ログイン、現在のユーザー情報の取得)を提供する役割を持っています。

コード解説

auth_api.dart
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../core/failure.dart';
import '../core/providers.dart';

// FirebaseAuthAPIインスタンスをアプリケーションの他の部分で利用できるようにする。
final authAPIProvider = Provider((ref) {
  final auth = ref.watch(firebaseAuthProvider);
  return FirebaseAuthAPI(auth: auth);
});

abstract class AuthAPI {
  Future<User?> signUp({required String email, required String password});
  Future<User?> login({required String email, required String password});
  User? currentUser();
}

class FirebaseAuthAPI implements AuthAPI {
  final FirebaseAuth _auth;

  FirebaseAuthAPI({required FirebaseAuth auth}) : _auth = auth;

  // メールアドレスとパスワードを使用して新規ユーザーをFirebaseに登録します。
  
  Future<User?> signUp({required String email, required String password}) async {
    try {
      final userCredential = await _auth.createUserWithEmailAndPassword(email: email, password: password);
      return userCredential.user;
    } on FirebaseAuthException catch (e) {
      throw Failure(message: e.message ?? "サインアップ中に予期せぬエラーが発生しました。");
    }
  }

  // メールアドレスとパスワードを使用して既存のユーザーを認証します。
  
  Future<User?> login({required String email, required String password}) async {
    try {
      final userCredential = await _auth.signInWithEmailAndPassword(email: email, password: password);
      return userCredential.user;
    } on FirebaseAuthException catch (e) {
      throw Failure(message: e.message ?? "ログイン中に予期せぬエラーが発生しました。");
    }
  }

  // 現在のユーザー (currentUserメソッド): 現在ログインしているFirebaseユーザーの情報を返します。
  
  User? currentUser() {
    return _auth.currentUser;
  }
}

FirebaseAuthAPIクラスがAuthAPIインターフェースを実装している点に注目します。
ここで、Firebase Authenticationの主要なメソッド(signUp, login, currentUser)を定義しています。

ポイントのまとめ

  • 抽象化と実装: AuthAPIインターフェースによって認証メソッドの抽象化を行い、FirebaseAuthAPIクラスでこれらのメソッドを具体的に実装しています。これにより、アプリケーションの他の部分は、具体的な認証サービスの実装詳細を意識せずに認証機能を利用できるようになります。
  • アプリケーション全体へのアクセス性: authAPIProviderを通じて、アプリケーションのどこからでも認証機能にアクセスできるようにします。これにより、認証状態の管理が一元化され、状態管理の複雑性が軽減されます。

user_api.dartの概要

Firebase Firestoreを用いてユーザーデータを保存し、取得するための機能を提供する役割を持っています。
このクラスはアプリ内のユーザー情報の保存とアクセスを管理し、Firebase Firestoreとの間でデータを同期します。ここでは、saveUserDataメソッドでユーザー情報をFirestoreに保存し、getUserDataメソッドで特定のUIDを持つユーザーの情報を取得する機能を提供します。

コード解説

user_api.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:x_clone_firebase/core/providers.dart';
import '../core/failure.dart';
import '../models/user_model.dart';

// FirebaseUserAPIインスタンスをアプリケーションの他の部分で利用できるようにする。
final userAPIProvider = Provider((ref) {
  return FirebaseUserAPI(firestore: ref.watch(firestoreProvider));
});

abstract class UserAPI {
  Future<void> saveUserData(UserModel userModel);
  Future<Map<String, dynamic>?> getUserData(String uid);
}

class FirebaseUserAPI implements UserAPI {
  final FirebaseFirestore _firestore;

  FirebaseUserAPI({required FirebaseFirestore firestore}) : _firestore = firestore;

  // Firestoreの`users`コレクションにユーザー情報を保存します。ユーザーモデルをJSONに変換して使用します。
  
  Future<void> saveUserData(UserModel userModel) async {
    try {
      await _firestore.collection("users").doc(userModel.uid).set(userModel.toJson());
    } on FirebaseException catch (e) {
      throw Failure(message: e.message ?? "Some unexpected error occurred");
    } catch (e) {
      throw Failure(message: e.toString());
    }
  }


  // firestoreにユーザーデータが存在する場合、そのデータをMap<String, dynamic>として返します。
  
  Future<Map<String, dynamic>?> getUserData(String uid) async {
    try {
      DocumentSnapshot userSnapshot = await _firestore.collection("users").doc(uid).get();
      if (userSnapshot.exists) {
        // ユーザーデータが存在する場合、そのデータをMap<String, dynamic>として返します。
        return userSnapshot.data() as Map<String, dynamic>?;
      } else {
        // ユーザーデータが存在しない場合はnullを返します。
        return null;
      }
    } on FirebaseException catch (e) {
      // Firestoreからデータを取得中に発生したエラーを処理します。
      throw Failure(message: e.message ?? "An unexpected error occurred");
    } catch (e) {
      // その他のエラーを処理します。
      throw Failure(message: e.toString());
    }
  }

}

user_api.dartファイルは、Firebase Firestoreとのインタラクションを担当するFirebaseUserAPIクラスを定義しています。このクラスはUserAPIインターフェイスを実装し、Firestoreのusersコレクションにユーザーデータを保存または取得する具体的な方法を提供します。

  • FirebaseUserAPIインスタンスの提供:userAPIProviderを用いて、アプリ全体からFirebaseUserAPIインスタンスにアクセスできるようにします。
    これはRiverpodを使用して実装され、FirestoreインスタンスはfirestoreProviderから取得されます。

  • saveUserDataメソッド(データの保存):UserModelのインスタンスを受け取り、そのデータをFirestoreのusersコレクションに保存します。データはuserModel.toJson()を呼び出すことでJSON形式に変換され、ドキュメントIDとしてユーザーのUIDが使用されます。

  • getUserDataメソッド(データの取得):指定されたUIDを持つユーザーのデータをFirestoreから非同期に取得します。データが存在する場合はUserModel.fromJsonを用いて取得したデータをUserModelに変換し、返します。

ポイントのまとめ

  • データ永続化とアクセス: FirebaseUserAPIクラスはFirebase Firestoreを介してアプリケーションのユーザーデータを管理します。これにより、アプリのどこからでもユーザーデータの保存と取得が可能になります。

  • Riverpodの利用: userAPIProviderを通じて、FirebaseUserAPIインスタンスへのアクセスをアプリ全体で容易にします。これにより、状態管理が一元化され、コードの可読性と保守性が向上します。

  • セキュリティとデータ整合性: ユーザー情報の取り扱いには、セキュリティが重要です。Firestoreのusersコレクションへのアクセス制御を適切に設定し、データの整合性を保つことが重要です。

View (features/auth/view/)

MVCの[V]
View(UI): ユーザーに情報を表示し、ユーザーの入力を受け取るインターフェースです。見た目(UI)に関わります。

SignUpView

signup_view
signup_view
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';


import '../../../common/common.dart';
import '../../../constants/ui_constants.dart';
import '../../../theme/theme.dart';
import '../controller/auth_controller.dart';
import '../widgets/auth_field.dart';
import 'login_view.dart';

class SignUpView extends ConsumerStatefulWidget {
  static route() => MaterialPageRoute(
        builder: (context) => SignUpView(),
      );

  const SignUpView({super.key});

  
  ConsumerState<SignUpView> createState() => _SignUpViewState();
}

class _SignUpViewState extends ConsumerState<SignUpView> {
  final appbar = UIConstants.appBar(); 
  final emailController = TextEditingController();
  final passwordController = TextEditingController();

  
  void dispose() {
    super.dispose();
    emailController.dispose();
    passwordController.dispose();
  }

  void onSignUp() {
    ref.read(authControllerProvider.notifier).signUp(
          email: emailController.text,
          password: passwordController.text,
          context: context,
        );
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: appbar,
      body: Center(
        child: SingleChildScrollView(
          child: Padding(
            padding: const EdgeInsets.symmetric(horizontal: 20),
            child: Column(
              children: [
                // e-mail
                AuthField(
                  controller: emailController,
                  hintText: 'Email address',
                ),
                SizedBox(height: 25),
                // password
                AuthField(
                  controller: passwordController,
                  hintText: 'パスワード',
                ),
                SizedBox(height: 40),
                // button
                Align(
                  alignment: Alignment.topRight,
                  child: RoundedSmallButton(
                    onTap: onSignUp, // sign up 処理
                    label: "Done",
                  ),
                ),
                SizedBox(height: 40),
                RichText(
                  text: TextSpan(
                      text: "Don't have an account?",
                      style: TextStyle(
                        fontSize: 16,
                      ),
                      children: [
                        TextSpan(
                          text: " Login",
                          style: TextStyle(
                            color: Pallete.blueColor,
                            fontSize: 16,
                          ),
                          recognizer: TapGestureRecognizer()
                            ..onTap = () {
                              Navigator.push(
                                context,
                                LoginView.route(),
                              );
                            },
                        ),
                      ]),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

主要なコンポーネント

  • E-mailフィールド
  • パスワードフィールド
  • 完了ボタン("Done"): ユーザーが入力した情報でサインアップを試みるためのボタンです。タップすると、入力されたメールアドレスとパスワードで新規アカウントの作成が開始されます。
  • ログインリンク: すでにアカウントを持っているユーザーがログイン画面へ移動できるリンクです。"Don't have an account? Login"というテキストで、ユーザーがタップするとログイン画面(LoginView)へ遷移します。

機能

  • 新規登録: ユーザーが提供したメールアドレスとパスワードを使用してFirebase Authを介して新規登録を行います。登録成功後、ユーザーはlogin_viewに画面遷移されます。
  • 入力検証: メールアドレスとパスワードの入力が適切かどうかを検証し、不適切な場合はユーザーにフィードバックを提供します。
  • 画面遷移: ユーザーがすでにアカウントを持っている場合、ログイン画面へ簡単に移動できるようにします。

LoginView

login_view
login_view
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:x_clone_firebase/features/auth/view/signup_view.dart';


import '../../../common/common.dart';
import '../../../constants/ui_constants.dart';
import '../../../theme/pallete.dart';
import '../controller/auth_controller.dart';
import '../widgets/auth_field.dart';

class LoginView extends ConsumerStatefulWidget {
  static route() => MaterialPageRoute(
        builder: (context) => LoginView(),
      );

  const LoginView({super.key});

  
  ConsumerState<LoginView> createState() => _LoginViewState();
}

class _LoginViewState extends ConsumerState<LoginView> {
  final appbar = UIConstants.appBar();
  final emailController = TextEditingController();
  final passwordController = TextEditingController();

  
  void dispose() {
    super.dispose();
    emailController.dispose();
    passwordController.dispose();
  }

  void onLogin() {
    ref.read(authControllerProvider.notifier).login(
          email: emailController.text,
          password: passwordController.text,
          context: context,
        );
  }

  
  Widget build(BuildContext context) {
    final isLoading = ref.watch(authControllerProvider);
    return Scaffold(
      appBar: appbar,
      body: isLoading
          ? const Loader()
          : Center(
              child: SingleChildScrollView(
                child: Padding(
                  padding: const EdgeInsets.symmetric(horizontal: 20),
                  child: Column(
                    children: [
                      // e-mail
                      AuthField(
                        controller: emailController,
                        hintText: 'Email address',
                      ),
                      SizedBox(height: 25),
                      // password
                      AuthField(
                        controller: passwordController,
                        hintText: 'パスワード',
                      ),
                      SizedBox(height: 40),
                      // button
                      Align(
                        alignment: Alignment.topRight,
                        child: RoundedSmallButton(
                          onTap: onLogin, // ログイン処理
                          label: "Done",
                        ),
                      ),
                      SizedBox(height: 40),
                      RichText(
                        text: TextSpan(
                            text: "Don't have an account?",
                            style: TextStyle(
                              fontSize: 16,
                            ),
                            children: [
                              TextSpan(
                                text: " Sign up",
                                style: TextStyle(
                            color: Pallete.blueColor,
                            fontSize: 16,
                          ),
                          recognizer: TapGestureRecognizer()
                            ..onTap = () {
                              Navigator.push(
                                context,
                                SignUpView.route(),
                              );
                            },
                        ),
                      ]),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}

主要なコンポーネント

  • E-mailフィールド
  • パスワードフィールド
  • 完了ボタン("Done"): 入力された情報を用いてログインを実行するボタンです。タップすると、認証プロセスが開始されます。
  • サインアップリンク: アカウントを持っていないユーザーがサインアップ画面に遷移できるリンクです。"Don't have an account? Sign up"と表示され、新規ユーザーを歓迎します。

機能

  • 認証: ユーザーはメールアドレスとパスワードを使用して認証を試みます。認証が成功すれば、home_viewに画面遷移します。
  • 入力検証: 入力されたメールアドレスとパスワードが適切であるか検証され、問題がある場合はユーザーに通知されます。
  • アカウント登録: 新規ユーザーは、サインアップリンクをタップすることで登録画面に遷移できます。

widgets/auth_field.dart

TextFieldはwidgetsに書いて共通化しています。

auth_field.dart
auth_field.dart
import 'package:flutter/material.dart';

import '../../../theme/theme.dart';

class AuthField extends StatelessWidget {
  final TextEditingController controller;
  final String hintText;

  const AuthField(
      {super.key, required this.controller, required this.hintText});

  
  Widget build(BuildContext context) {
    return TextField(
      style: TextStyle(
        fontWeight: FontWeight.bold,
      ),
      controller: controller,
      decoration: InputDecoration(
          focusedBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(5),
            borderSide: BorderSide(color: Pallete.blueColor, width: 3),
          ),
          enabledBorder: OutlineInputBorder(
            borderRadius: BorderRadius.circular(5),
            borderSide: BorderSide(
              color: Pallete.greyColor,
            ),
          ),
          contentPadding: EdgeInsets.all(22),
          hintText: hintText,
          hintStyle: TextStyle(
            fontSize: 18,
          )),
    );
  }
}

Controller(features/auth/controller/)

MVCの[C]
ControllerはアプリケーションのUI(View)とデータ(Model)の間の仲介役です。ユーザーからの入力を受け、それに基づいてModelを更新し、変更をViewに反映させます。このプロセスにより、ユーザーインタラクションとアプリケーションのロジックが密接に結びつきます。

auth_controller.dartの概要

役割と機能

AuthControllerViewから受け取ったユーザーのメールアドレスとパスワードをModel層へと渡し、Firebase Authenticationを介してサインアップやログインを実行します。認証成功後のユーザーデータは、同じくModel層のFirebase Firestoreへ保存されます

コード解説

auth_controller.dartの全体コード
auth_controller.dart
import 'package:firebase_auth/firebase_auth.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import '../../../core/failure.dart';
import '../../../core/utils.dart';
import '../../../firebase/auth_api.dart';
import '../../../firebase/user_api.dart';
import '../../../models/user_model.dart';
import '../../home/view/home_view.dart';
import '../view/login_view.dart';

// アプリケーション全体でAuthControllerを利用可能にします。これにより、認証状態の管理とFirebase認証機能へのアクセスが行えます。
final authControllerProvider = StateNotifierProvider<AuthController, bool>((ref) {
  return AuthController(
    authApi: ref.watch(authAPIProvider),
    userApi: ref.watch(userAPIProvider),
  );
});

// 現在ログインしているユーザーの詳細情報を非同期で取得します。
final currentUserDetailsProvider = FutureProvider((ref) async {
  final currentUserId = ref.watch(currentUserAccountProvider).value!.uid;
  final userDetails = await ref.watch(userDetailsProvider(currentUserId).future);
  return userDetails;
});

// 特定のUIDを持つユーザーの詳細情報を非同期で取得します。
final userDetailsProvider = FutureProvider.family<UserModel, String>((ref, String uid) async {
  final authController = ref.watch(authControllerProvider.notifier);
  return await authController.getUserData(uid);
});

// AuthControllerを通じてFirebaseAuthから現在ログインしているユーザーの情報を取得します。
final currentUserAccountProvider = FutureProvider<User?>((ref) async {
  final authController = ref.watch(authControllerProvider.notifier);
  return authController.currentUser();
});

class AuthController extends StateNotifier<bool> {

  // FirebaseAuthAPIを介してFirebase Authenticationの機能にアクセスします。
  final FirebaseAuthAPI _authAPI;

  // FirebaseUserAPIを介してFirestoreに保存されたユーザーデータの操作を行います。
  final FirebaseUserAPI _userAPI;

  AuthController({required FirebaseAuthAPI authApi, required FirebaseUserAPI userApi})
      : _authAPI = authApi,
        _userAPI = userApi,
        super(false);

  // 現在ログインしているユーザーをFirebaseAuthから取得します。
  User? currentUser() => _authAPI.currentUser();

  // 新規ユーザーをFirebaseに登録し、Firestoreにユーザー情報を保存します。
  void signUp({
    required String email,
    required String password,
    required BuildContext context,
  }) async {
    state = true;
    try {
      final user = await _authAPI.signUp(email: email, password: password);
      UserModel userModel = UserModel(
        email: email,
        name: getNameFromEmail(email),
        followers: const [],
        following: const [],
        profilePic: '',
        bannerPic: '',
        uid: user!.uid,
        bio: '',
        isTwitterBlue: false,
      );
      await _userAPI.saveUserData(userModel);
      showSnackBar(context, 'Account created! Please login.');
      Navigator.pushReplacement(context, MaterialPageRoute(builder: (context) => LoginView()));
    } on Failure catch (f) {
      showSnackBar(context, f.message);
    } finally {
      state = false;
    }
  }

  // メールアドレスとパスワードを使用してログインを試み、成功した場合はホーム画面に遷移します。
  void login({
    required String email,
    required String password,
    required BuildContext context,
  }) async {
    state = true;
    try {
      await _authAPI.login(email: email, password: password);
      Navigator.pushReplacement(context, MaterialPageRoute(builder: (context) => HomeView()));
    } on Failure catch (f) {
      showSnackBar(context, f.message);
    } finally {
      state = false;
    }
  }

  // 指定されたUIDに基づきFirestoreからユーザーデータを取得し、UserModelに変換して返します。
  Future<UserModel> getUserData(String uid) async {
    try {
      final document = await _userAPI.getUserData(uid);
      return UserModel.fromJson(document as Map<String, dynamic>);
    } on Failure catch (f) {
      throw Exception(f.message);
    }
  }
}

1.authControllerProvider

final authControllerProvider = StateNotifierProvider<AuthController, bool>((ref) {
  return AuthController(
    authApi: ref.watch(authAPIProvider),
    userApi: ref.watch(userAPIProvider),
  );
});
  • StateNotifierProviderを用いたauthControllerの提供:
    authControllerProviderStateNotifierProviderを用いて定義されており、アプリケーション全体でAuthControllerのインスタンスにアクセスできるようにします。これにより、認証状態の変化をアプリケーション全体でリアクティブに扱うことが可能になります。

2.ユーザー情報の非同期取得


// 現在ログインしているユーザーの詳細情報を非同期で取得します。
final currentUserDetailsProvider = FutureProvider((ref) async {
  final currentUserId = ref.watch(currentUserAccountProvider).value!.uid;
  final userDetails = await ref.watch(userDetailsProvider(currentUserId).future);
  return userDetails;
});

// 特定のUIDを持つユーザーの詳細情報を非同期で取得します。
final userDetailsProvider = FutureProvider.family<UserModel, String>((ref, String uid) async {
  final authController = ref.watch(authControllerProvider.notifier);
  return await authController.getUserData(uid);
});

// AuthControllerを通じてFirebaseAuthから現在ログインしているユーザーの情報を取得します。
final currentUserAccountProvider = FutureProvider<User?>((ref) async {
  final authController = ref.watch(authControllerProvider.notifier);
  return authController.currentUser();
});
  • ユーザー情報プロバイダー:
    • currentUserDetailsProviderは、現在ログインしているユーザーの詳細情報を非同期で取得します。これはFutureProviderを使用しており、アプリケーション内のどこからでも現在のユーザー情報へのアクセスを可能にします。

    • userDetailsProviderは、特定のユーザーIDに基づいてそのユーザーの詳細情報を取得するために使用されます。これもFutureProvider.familyを使い、動的な引数(この場合はユーザーID)に基づいてデータを提供します。これにより、特定のユーザーに関する情報を柔軟に取得することが可能になります。

    • currentUserAccountProviderは、AuthControllerを通じてFirebase Authenticationから現在ログインしているユーザーのアカウント情報を取得します。これはアプリケーションのさまざまな場所でユーザーがログインしているかどうかを判断するために利用されます。

3.AuthControllerクラス

class AuthController extends StateNotifier<bool> {

  // FirebaseAuthAPIを介してFirebase Authenticationの機能にアクセスします。
  final FirebaseAuthAPI _authAPI;

  // FirebaseUserAPIを介してFirestoreに保存されたユーザーデータの操作を行います。
  final FirebaseUserAPI _userAPI;

  AuthController({required FirebaseAuthAPI authApi, required FirebaseUserAPI userApi})
      : _authAPI = authApi,
        _userAPI = userApi,
        super(false);

  // 現在ログインしているユーザーをFirebaseAuthから取得します。
  User? currentUser() => _authAPI.currentUser();

  // 新規ユーザーをFirebaseに登録し、Firestoreにユーザー情報を保存します。
  void signUp({
    required String email,
    required String password,
    required BuildContext context,
  }) async {
    state = true;
    try {
      final user = await _authAPI.signUp(email: email, password: password);
      UserModel userModel = UserModel(
        email: email,
        name: getNameFromEmail(email),
        followers: const [],
        following: const [],
        profilePic: '',
        bannerPic: '',
        uid: user!.uid,
        bio: '',
        isTwitterBlue: false,
      );
      await _userAPI.saveUserData(userModel);
      showSnackBar(context, 'Account created! Please login.');
      Navigator.pushReplacement(context, MaterialPageRoute(builder: (context) => LoginView()));
    } on Failure catch (f) {
      showSnackBar(context, f.message);
    } finally {
      state = false;
    }
  }

  // メールアドレスとパスワードを使用してログインを試み、成功した場合はホーム画面に遷移します。
  void login({
    required String email,
    required String password,
    required BuildContext context,
  }) async {
    state = true;
    try {
      await _authAPI.login(email: email, password: password);
      Navigator.pushReplacement(context, MaterialPageRoute(builder: (context) => HomeView()));
    } on Failure catch (f) {
      showSnackBar(context, f.message);
    } finally {
      state = false;
    }
  }

  // 指定されたUIDに基づきFirestoreからユーザーデータを取得し、UserModelに変換して返します。
  Future<UserModel> getUserData(String uid) async {
    try {
      final document = await _userAPI.getUserData(uid);
      return UserModel.fromJson(document as Map<String, dynamic>);
    } on Failure catch (f) {
      throw Exception(f.message);
    }
  }
}

  • サインアップとログイン機能:
    signUpメソッドloginメソッドは、Firebase Authenticationを使ってユーザーの認証を行います。サインアップでは新規ユーザーをFirebaseに登録し、成功すればFirestoreにユーザーデータを保存します。ログインでは既存のユーザー認証を試み、成功すればホーム画面に遷移します。

  • ユーザーデータの取得と管理:
    getUserDataメソッドは、特定のユーザーIDに基づいてFirestoreからユーザーデータを取得し、それをUserModelに変換して返します。これにより、アプリケーション内で必要なユーザー情報を簡単に取得できます。

Core(MVCとは直接は関係ない)

それぞれのファイルで定義したRiverpodを、アプリケーション全体で簡単にアクセスできるようにProviderを設定しています。

Core/providers

providers
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:firebase_storage/firebase_storage.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Firebase Authenticationのインスタンスを提供するProvider
final firebaseAuthProvider = Provider<FirebaseAuth>((ref) {
  return FirebaseAuth.instance;
});

// 現在ログインしているユーザーを追跡するProvider
final authStateChangesProvider = StreamProvider<User?>((ref) {
  return ref.watch(firebaseAuthProvider).authStateChanges();
});

// Cloud Firestoreのインスタンスを提供するProvider
final firestoreProvider = Provider<FirebaseFirestore>((ref) {
  return FirebaseFirestore.instance;
});

// Firebase Storageのインスタンスを提供するProvider
final storageProvider = Provider<FirebaseStorage>((ref) {
  return FirebaseStorage.instance;
});

main.dart

currentUserAccountProviderを使って、現在ログインしているユーザーの情報を監視します。このプロバイダーは、ユーザーがログインしているかどうかに基づいて異なる状態(データ、エラー、ローディング)を返します。

main.dart
import 'package:firebase_core/firebase_core.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'common/error_page.dart';
import 'common/loading_page.dart';
import 'features/auth/controller/auth_controller.dart';
import 'features/auth/view/signup_view.dart';
import 'features/home/view/home_view.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp();
  runApp(const ProviderScope(child: MyApp()));
}

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

  
  Widget build(BuildContext context,WidgetRef ref) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),

        home: ref.watch(currentUserAccountProvider).when(
          data: (user) {
            if (user != null) {
            // ユーザーがログインしている場合
              return const HomeView();
            }
            // ユーザーがログインしていない場合
            return SignUpView();
          },
          // エラーになった時
          error: (e, st) => ErrorPage(
            error: e.toString(),
          ),
          // ロード中
          loading: () => LoadingPage(),
        )
    );
  }
}
  • 条件に応じた画面表示:
    • ログイン済み: ユーザーがログインしている場合、ホーム画面 (HomeView) を表示します。
    • 未ログイン: ユーザーがログインしていない場合、サインアップ画面 (SignUpView) を表示します。
    • ローディング中: ユーザー情報の取得中は、ローディング画面 (LoadingPage) を表示します。
    • エラー発生: ユーザー情報の取得時にエラーが発生した場合、エラー画面 (ErrorPage) を表示します。

実際の処理の流れ

signUp機能の流れ

  • view: メールアドレス、パスワードを入力する、onSignUp()
    👇
  • contorller: signUp()
    👇
  • model: FirebaseAuthAPIFuture<User?> signUp
    👇
  • model: FirebaseUserAPIFuture<void> saveUserData(UserModel userModel)
    👇
    画面遷移⭕️

login機能の流れ

  • view: メールアドレス、パスワードを入力する、onLogin()
    👇
  • contorller: login()
    👇
  • model: FirebaseAuthAPIFuture<User?> login
    👇
    画面遷移⭕️

自動ログインの流れ

  • main.dart: currentUserAccountProviderが呼ばれる
    👇
  • contorller: User? currentUser() => _authAPI.currentUser();
    👇
  • main.dart: Userが返ってきたら、return const HomeView(); 画面遷移⭕️

まとめ

今回は、MVCアーキテクチャを用いてFirebaseを活用し、サインアップ機能(新規ユーザー登録)とログイン機能(既存ユーザーの認証)を実装してみました。私はこれまで特にアーキテクチャにこだわらずにコーディングをしてきました。特に個人開発では、その必要性を感じることは少なかったですが、Flutterエンジニアとしてのキャリアを目指し、就職活動で自身の作品をポートフォリオとして提出する際、企業からアーキテクチャに関する質問を受けたり、コードを詳細に見てもらう機会が多くありました。その経験から、コードはなるべくクリーンに保つ重要性を実感しました。

長文になりましたが、最後までお読みいただき、ありがとうございました!

今回使った主なパッケージ

freezed
https://pub.dev/packages/freezed

freezed_annotation
https://pub.dev/packages/freezed_annotation

riverpod
https://pub.dev/packages/riverpod

state_provider
https://riverpod.dev/docs/providers/state_provider

future_provider
https://riverpod.dev/docs/providers/future_provider

参考にした動画

https://youtu.be/njLEDvoDjtk?si=kcyIBWiQFpG8TSV3

Discussion