Open1
ViewModelのメモ

モデル
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('投稿を更新'),
),
],
);
},
);
}
}