🐥

Flutter製SNSを2ヶ月で作るために【ユーザー登録編】

2021/05/07に公開

前回の記事ではSNSのFirebase設計的なことを書きました.
https://zenn.dev/yone04/articles/8230ccb77086fd
中産階級の消失が嘆かれて久しい昨今ですが,Flutter記事の中産階級を目指して執筆していきます.使っている状態管理はriverpod+StateNotifier+freezed+Hooksです.

今回は「Authにユーザー登録して,Firestoreにデータを保存するまで」を書いていきたいと思います.これができれば前回の記事と照らし合わせて,itemの投稿なども実装できると思います.

まず宣伝

作ったアプリはこれです.
https://apps.apple.com/jp/app/kidsroom/id1563660199
https://play.google.com/store/apps/details?id=com.yone.kids_room

以下本文

前回の記事でDataSourceを説明したため,DataSourceの説明は省かせていただきます.
もっとこうした方がいいなどありましたらコメントにて教えていただけると嬉しいです.

アーキテクチャ

アーキテクチャと言えるほど立派なものではないかもしれませんが,何となくロジックとUIが分離出来ていれば良いかなといった感じです.(アーキテクチャが理解できません.だから下の上の知識レベルです.)
前回の記事で軽く触れたAuthDataSourceとFirestoreDataSource,StorageDataSourceがあってそれらを管理するProviderがあります.それを使ってEntityを取得するRepositoryがあり,それらを管理するProviderがいます.イメージとしては以下のような感じです.

アーキテクチャ

Entity

まずは,Entityについてです.以下が実装になります.


class User with _$User {
  factory User({
    required String uid,
    required String name,
    required String imageUrl,
  }) = _User;
  const User._();

  factory User.fromDocument(DocumentSnapshot document) {
    return User(
      uid: document.id,
      name: document.data()!['name'] as String,
      imageUrl: document.data()!['imageUrl'] as String,
    );
  }
  
    factory User.listFromDocument(QueryDocumentSnapshot document) {
      return User(
      uid: document.id,
      name: document.data()['name'] as String,
      imageUrl: document.data()['imageUrl'] as String,
    );
  }


  Map<String, dynamic> toMap() {
    return <String, dynamic>{
      'name': name,
      'imageUrl': imageUrl,
    };
  }
}

freezedを使用してimmutableにしています.User.fromDocumentはFirestoreからデータを取得したDocumentSnapshotからUserクラスに変換するための名前付きコンストラクタです.toMapは反対にUserクラスからFirestoreに保存できるMapに変換するためのメソッドです.

Repository

ここが結構悩ましいところで,実際のコードでは実装していません.というのも,以下の実装を見ていただければ分かると思うのですが,少し冗長ぎみになるからです.

abstract class IUserRepository {
  Future<User> getUser(String uid);
  Future<User> setUser(String uid, String userName, File imageFile);
}


class UserRepository extends IUserRepository {
  UserRepository({
    required this.iFirestoreDataSource,
    required this.iStorageDataSource,
  });

  final IFirestoreDataSource iFirestoreDataSource;
  final IStorageDataSource iStorageDataSource;

  
  Future<User> getUser(String uid) async {
    final user = await iFirestoreDataSource.getUserData(uid).then((DocumentSnapshot snapshot) {
      return User.fromDocument(snapshot);
    });
    return user;
  }

  
  Future<User> setUser(String uid, String userName, File imageFile) async {
    final imageUrl = await iStorageDataSource.uploadUserIcon(imageFile, uid);
    final user = User(uid: uid, name: userName, imageUrl: imageUrl);
    await iFirestoreDataSource.setUserData(uid, user.toMap());
    return user;
  }
}

本来のRepositoryであれば,

Future<void> setUser(User user);

みたいな感じで済むはずです.しかし,storageを使って画像のURLを取得しないとUserインスタンスが生成できないため上述した実装になる気がします.また,「Entitiyを保持するStateNotifier」にUserを渡したいのでUserを返しています.僕は,なんか綺麗じゃないなと思ったので,実際には「PageごとのStateNotifier」の中でこの処理を行なっています(図中点線).本記事ではこのRepositoryを使った実装をしていくことにします.ちなみにRepositoryのProviderは以下のように定義して使います.

final userRepositoryProvider = Provider<IUserRepository>(
  (ref) => UserRepository(
    iFirestoreDataSource: ref.watch(firestoreDataSourceProvider),
    iStorageDataSource: ref.watch(storageDataSourceProvider),
  ),
);

Entityを保持するStateNotifier

ここではEntityを保持するStateNotifierの実装を示します.ログイン→タイムライン→マイページっていう動きをしたときに,ローカルにuserを持っとかないと画面遷移するたびにfirestoreにfetchすることになってしまいます.notifierがあればログインで取得したuserを持っといて,タイムラインとマイページでも使えます.また,以下の記事で言うところのmodelとmodel Locatorの役割をしているとも言えるかもしれません.

https://www.slideshare.net/mokemokechicken/iosandroidmodel

final userNotifier = StateNotifierProvider<UserNotifier, UserState>(
  (ref) => UserNotifier(),
);


class UserNotifier extends StateNotifier<UserState> {
  UserNotifier() : super(UserState());

  void setUser(User newUser) {
    state = state.copyWith(user: newUser);
  }
}



class UserState with _$UserState {
  factory UserState({
    (null) User? user,
  }) = _UserState;
}

PageごとのStateNotifier

前回の記事でも述べたように,Authの登録とFirestoreの登録は別々の画面で行うことにしています.以下がAuth登録ページのControllerです.

final registrationController = StateNotifierProvider.autoDispose<RegistrationController, RegistrationState>(
  (ref) => RegistrationController(
    IAuthDataSource: ref.watch(authDataSourceProvider),
  ),
);


class RegistrationController extends StateNotifier<RegistrationState> {
  RegistrationController({
    required this.authDataSource,
  }) : super(RegistrationState());

  final IAuthDataSource IAuthDataSource;

  Future<String> registration() async {
    final authUser = await IAuthDataSource.registerWithEmailAndPassword(state.email, state.password);
    return authUser!.uid;
  }

  void isButtonStateChange() {
    if (state.email.contains('@') && state.password.length > 5) {
      state = state.copyWith(isButtonAvailable: true);
    } else {
      state = state.copyWith(isButtonAvailable: false);
    }
  }

  void setEmail(String newEmail) {
    state = state.copyWith(email: newEmail);
    isButtonStateChange();
  }

  void setPassword(String newPassword) {
    state = state.copyWith(password: newPassword);
    isButtonStateChange();
  }
}



class RegistrationState with _$RegistrationState {
  factory RegistrationState({
    (false) bool isButtonAvailable,
    ('') String email,
    ('') String password,
  }) = _RegistrationState;
}

TextField系も全てStateNotiferで管理しています.こうすることでtestのしやすさ向上やStatefullWidgetの利用を避けることによる安易なリビルドを防ぐことができます.

UI側でregistration()メソッドを使って成功したらFirestoreにUserを保存するための画面へ遷移します.以下UIのbuttonを押した時の処理です.

onPressed: () async {
  // ProgressIndicator表示
  try {
    final uid = await context.read(registrationController.notifier).registration();
    replacementNavigator(context, SetUserPage(uid));
  } on FirebaseAuthException catch (e) {
    // エラーダイアログの表示
  } on SocketException {
    // エラーダイアログの表示
  }
   // ProgressIndicator非表示
},

戻れないようにNavigator.of(context, rootNavigator: true).pushReplacementを使っています.そしてSetUserPageのコンストラクタにAuthから得られたuidを渡してあげます.そのuidを使って今度はFirestoreにUser情報を保存してあげます.

final userSetController = StateNotifierProvider.autoDispose.family<UserSetController, UserSetState, String>(
  (ref, _uid) => SetUserPageController(
    iUserRepository: ref.watch(storageRepositoryProvider),
    userNotifier: ref.watch(userNotifier),
    uid: _uid,
  ),
);


class UserSetController extends StateNotifier<UserSetState> {
  UserSetController({
    required this.iUserRepository,
    required this.userNotifier,
    required this.uid,
  }) : super(UserSetState());

  final IUserRepository iUserRepository;
  final UserNotifier userNotifier;
  final String uid;

  Future<void> setUserData() async {
    // isButtonStateChangeでガードしている為state.image!
    final user = await iUserRepository.setUser(uid, state.name, state.image!);
    // repositoryで作成したuserEntityを使ってUserNotifierを初期化
    userNotifier.setUser(user);
  }

  void isButtonStateChange() {
    if (state.name.isNotEmpty && state.image != null) {
      state = state.copyWith(isButtonAvailable: true);
    } else {
      state = state.copyWith(isButtonAvailable: false);
    }
  }

  void setName(String newName) {
    state = state.copyWith(name: newName);
    isButtonStateChange();
  }

  Future<void> pickImage() async {
    final _picker = ImagePicker();
    final pickedFile = await _picker.getImage(source: ImageSource.gallery);

    if (pickedFile != null) {
      state = state.copyWith(image: File(pickedFile.path));
    }
    isButtonStateChange();
  }
}



class UserSetState with _$UserSetState {
  factory UserSetState({
    (false) bool isButtonAvailable,
    ('') String name,
    File? image,
  }) = _UserSetState;
}

重要なポイントはfamilyを使っている点です.

Page

pageの重要な部分は前章で触れた為,追記すべきことにのみ言及します.

TextField

TextFieldではProviderのメソッドを使って以下のように書けます.また,これらのcontrollerやfocusNodeはuseHooksを使わないとStatelessWidgetではエラーが発生します.


Widget build(BuildContext context) {
  final _focusNode = useFocusNode();
  final _controller = useTextEditingController();
  return TextField(
    focusNode: _focusNode,
    controller: _controller,
    onChanged: (value) {
      context.read(userSetController.notifier).setName(value);
    },
  );
}

splashページ

splashページでAuthのログイン情報を取得してどこのページに飛ばすか判定します.僕はHooksの初期化処理を使って判定しています。以下のパッケージでsplashを表示させた後にsecondary_splashとして以下コードの処理を行います。(そうしないと起動してすぐがブラック画面になります。詳細は以下パッケージで)
https://pub.dev/packages/flutter_native_splash

void init(BuildContext context) {
  Future.microtask(() async {
    final _currentUser = context.read(authRepositoryProvider).getCurrentUser();
    if (_currentUser != null) {
      try {
        await context.read(firestoreRepositoryProvider).getUserData(_currentUser.uid).then((DocumentSnapshot userSnapshot) {
          if (userSnapshot.exists) {
            final user = User.fromDocument(userSnapshot);
            // userNotifierの初期化処理
            context.read(userNotifier.notifier).setUser(user);
	    // Homeに移動
            replacementNavigator(context, HomePage());
          } else {
	    // Authだけ登録できているときはFirestoreのユーザー登録に移動
            replacementNavigator(context, SetUserPage(_currentUser.uid));
          }
        });
      } on SocketException {
        await alertSocketError(context);
      }
    } else {
      // Authの登録に移動
      replacementNavigator(context, RegistrationPage());
    }
  });
}

  
  Widget build(BuildContext context) {
    useEffect(() {
      init(context);
      return;
    }, const []);
    
    return Scaffold(
      // UIをここに記述
    );
  }

まとめ

本当ならEntityを保持するuserNotifierでRepositoryを使いたい気がするのですが,何となくうまくいかない(userNotifierのsetUserメソッドがspalashページとSetUserページのどちらか未確定)なため現在のような実装になっています.何かアドバイス・質問等ありましたらどんどんコメントしてください.この記事がどこかで誰かの役に立つことを願っています.

Discussion