Open1

ViewModelのメモ

JboyHashimotoJboyHashimoto

モデル

Freezedでモデルクラスを定義して、これを使って入力フォームの値をFirestoreに渡す。

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:ore_chans_app/firebase/src/features/post_crud_app/domain/converter/timestamp_converter.dart';

part 'post.freezed.dart';
part 'post.g.dart';

/// [コマンドを実行したら起動したままにするwatchがついたコマンド]
/// watchがあると、毎回コマンドを実行しなくて良くなる。
// flutter pub run build_runner watch --delete-conflicting-outputs

/// [postコレクションのドキュメントのモデルクラス]
/*Freezedを使うと何がうれしいのか?
toJson()やfromJson()を自動生成してくれるので、
モデルクラスを作るときに、
toJson()やfromJson()を書く手間が省ける。
copyWith()も自動生成してくれるので、
コンストラクターの引数を省略したいときに便利。
 */

class Post with _$Post {
  const factory Post({
    String? id,// ドキュメントIDを指定するのに使う
    ('') String body,// 投稿の本文
    () DateTime? createdAt,// 投稿の作成日時
    () DateTime? updatedAt,// 投稿の更新日時
  }) = _Post;

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

ロジック

repositoryにロジックは書いておく。

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:ore_chans_app/firebase/src/features/post_crud_app/application/post_provider.dart';
import 'package:ore_chans_app/firebase/src/features/post_crud_app/domain/post/post.dart';

// 抽象クラスを作成。ここに追加、更新、削除のメソッドを書く。
abstract class PostRepository {
  Future<void> addPost(Post post);
  Future<void> updatePost(Post post);
  Future<void> deletePost(String? postID);
}

/*
- 抽象クラスを継承して、メソッドを実装する。
- withConverterを使用して、渡す引数にtoJsonを書かずに済むようにした。
- updateのところだけは、渡さないといけないので、toJsonを書いている。
*/
class PostService implements PostRepository {
  PostService(this.ref);
  Ref ref; // Refを使えるように、コンストラクタで受け取る。

  // 追加のメソッド
  
  Future<void> addPost(Post post) async {
    try {
      final postRef = ref.read(postReferenceWithConverter);
      postRef.add(post);
    } catch (e) {
      throw e;
    }
  }

  // 更新のメソッド
  
  Future<void> updatePost(Post post) async {
    try {
      final postRef = ref.read(postReferenceWithConverter);
      postRef.doc(post.id).update(post.toJson());
    } catch (e) {
      throw e;
    }
  }

  // 削除のメソッド
  
  Future<void> deletePost(String? postID) async {
    try {
      final postRef = ref.read(postReferenceWithConverter);
      postRef.doc(postID).delete();
    } catch (e) {
      throw e;
    }
  }
}
// PostServiceを使えるようにするプロバイダー
final postServiceProvider = Provider((ref) => PostService(ref));

ViewModel

ロジックは書かれていなくて、状態を管理するだけ。

import 'dart:async';

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:ore_chans_app/firebase/src/features/post_crud_app/data/post_repository.dart';
import 'package:ore_chans_app/firebase/src/features/post_crud_app/domain/post/post.dart';

// PostNotifierを使えるようにするプロバイダー
final postNotifierProvider =
    AsyncNotifierProvider<PostNotifier, void>(PostNotifier.new);

/*
- View Modelの役割をするAsyncNotifier
- ViewとModelの間にこのクラスを挟むことで、ViewとModelを分離する。
- 入力フォームで null が入力されたら、ref.listenでエラーを表示する。
 */
class PostNotifier extends AsyncNotifier<void> {
  
  FutureOr<void> build() {
    // 戻り値がなければ書かない。
  }

  // 投稿データを追加
  Future<void> addPost(Post post) async {
    // 入力された値がnullだったら、ref.listenでエラーを表示する
    if (post.body.isEmpty) {// isEmptyは値が空かどうかを判定する
      state = const AsyncError<void>('投稿失敗: 投稿内容が入力されてません', StackTrace.empty);
    }
    // 入力された値がnullじゃなかったら、投稿する
    else {
      try {
        state = const AsyncLoading();
        await ref.read(postServiceProvider).addPost(post);
        state = const AsyncValue<void>.data(null); // Successfully added
        // ignore: avoid_catches_without_on_clauses
      } catch (e, stackTrace) {
        // ignore: noop_primitive_operations
        state = AsyncError<void>('投稿失敗: ${e.toString()}', stackTrace);
      }
    }
  }

  // 投稿データを更新
  Future<void> updatePost(Post post) async {
    try {
      state = const AsyncLoading();
      await ref.read(postServiceProvider).updatePost(post);
      state = const AsyncValue<void>.data(null); // Successfully updated
      // ignore: avoid_catches_without_on_clauses
    } catch (e, stackTrace) {
      // ignore: noop_primitive_operations
      state = AsyncError<void>('更新失敗: ${e.toString()}', stackTrace);
    }
  }

  /* 投稿データを削除
  ドキュメントIDはString?で渡されるようなので、引数の型をString?にしてnull許容にする必要があるみたいだ。
  */
  Future<void> deletePost(String? postID) async {
    try {
      state = const AsyncLoading();
      await ref.read(postServiceProvider).deletePost(postID);
      state = const AsyncValue<void>.data(null); // Successfully deleted
    } catch (e, stackTrace) {
      state = AsyncError<void>('削除失敗: ${e.toString()}', stackTrace);
    }
  }
}

View

エラーが出たら、ref.listenで表示させる。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:ore_chans_app/firebase/src/features/auth/application/auth_notifier.dart';
import 'package:ore_chans_app/firebase/src/features/post_crud_app/application/post_provider.dart';
import 'package:ore_chans_app/firebase/src/features/post_crud_app/domain/post/post.dart';
import 'package:ore_chans_app/firebase/src/features/post_crud_app/presentation/state/post_notifier.dart';

/// [ログイン後のページ]ここで、投稿と表示をする
class PostPage extends ConsumerWidget {
  const PostPage({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    final bodyController = TextEditingController();
    final editController = TextEditingController();
    // エラー処理用のref.listen
    ref.listen<AsyncValue<void>>(
      postNotifierProvider,
      (prev, next) {
        // nullのデータが入ってきたら、スナックバーを表示する
        // AsyncErrorが発生したら、isで判定して、データ型が同じになるので、スナックバーが出る。
        if (next is AsyncError) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(next.error.toString())),
          );
        }
      },
    );
    // Streamで全てのデータを取得する。
    final postAsyncValue = ref.watch(postStreamProvider);

    return Scaffold(
      appBar: AppBar(
        actions: [
          IconButton(
            icon: const Icon(Icons.lock),
            onPressed: () async {
              await ref.read(authNotifierProvider.notifier).signOut();
            },
          ),
        ],
        title: const Text('Post'),
      ),
      body: Center(
        child: Column(
          children: [
            const SizedBox(height: 20),
            SizedBox(
              width: 300,
              height: 50,
              child: TextFormField(
                controller: bodyController,
                decoration: const InputDecoration(
                  hintText: '本文',
                  border: OutlineInputBorder(),
                ),
              ),
            ),
            const SizedBox(height: 20),
            ElevatedButton(
                onPressed: () async {
                  final now = DateTime.now();
                  final post = Post(
                    body: bodyController.text,
                    createdAt: now,
                    updatedAt: now,
                  );
                  await ref.read(postNotifierProvider.notifier).addPost(post);
                },
                child: const Text('投稿')),
            Expanded(
              child: postAsyncValue.when(
                data: (posts) {
                  return ListView.builder(
                    itemCount: posts.length,
                    itemBuilder: (context, index) {
                      final post = posts[index];
                      return ListTile(
                        title: Text(post.body),
                        trailing: SizedBox(
                          width: 120,
                          child: Row(
                            children: [
                              IconButton(
                                icon: const Icon(Icons.edit),
                                onPressed: () async {
                                  // ボタンを押すと編集用のダイアログが現れる
                                  updateDialog(context, editController, posts,
                                      index, ref);
                                },
                              ),
                              const SizedBox(width: 20),
                              IconButton(
                                icon: const Icon(Icons.delete),
                                onPressed: () async {
                                  final postId = posts[index].id;
                                  await ref
                                      .read(postNotifierProvider.notifier)
                                      .deletePost(postId);
                                },
                              ),
                            ],
                          ),
                        ),
                      );
                    },
                  );
                },
                loading: () => const Center(
                  child: CircularProgressIndicator(),
                ),
                error: (error, stackTrace) => Center(
                  child: Text(error.toString()),
                ),
              ),
            ),
          ],
        ),
      ),
    );
  }

  // ダイアログをコンポーネント化
  Future<void> updateDialog(
      BuildContext context,
      TextEditingController editController,
      List<Post> posts,
      int index,
      WidgetRef ref) {
    return showDialog(
      context: context,
      builder: (context) {
        return AlertDialog(
          title: const Text('投稿'),
          content: TextFormField(
        controller: editController,
            decoration: const InputDecoration(
              hintText: '本文',
              border: OutlineInputBorder(),
            ),
          ),
          actions: [
            TextButton(
              onPressed: () async {
                final now = DateTime.now();
                final post = Post(
                  id: posts[index].id,
                  body: editController.text,
                  createdAt: now,
                  updatedAt: now,
                );
                await ref.read(postNotifierProvider.notifier).updatePost(post);
                editController.clear();
                if (context.mounted) {
                  Navigator.pop(context);
                }
              },
              child: const Text('投稿を更新'),
            ),
          ],
        );
      },
    );
  }
}