Flutter Provider を TODO List を実装して理解する
Flutter Provider を TODO List を実装して理解します。
概要
前回は「Flutter の Provider を公式の説明を読んで爆速で理解する」こちらの記事で、Providerの概要を公式の説明ページを読んで理解しました。これだけだとまだ理解が浅いので、今回はTODO Listの実装を通して理解を深めようと思います。以下の記事がとてもシンプルで分かり易かったので、参考にさせていただきます。
https://souravsarkaremon16.medium.com/create-a-simple-todo-app-with-flutter-provider-ffabfe4c9d2f
完成イメージ
以下のようなアプリを実装します。
手順
flutter project の作成
$ flutter create todoapp
provider パッケージの追加
$ cd todoapp
$ fluter pub add provider
ここまでやったら、お気に入りのIDEでプロジェクトを開きます。(私はIntelliJ Ultimateです)
一応自動生成されたカウンターアップが動くことを確認しておきます。
Todoモデルの作成
widget以外のtodo関連のファイルはlibの下に todo というディレクトリを切ってその中に集めます。まずTodoのモデルを作成します。
class Todo {
final String title;
bool completed;
Todo({required this.title, this.completed = false});
void toggleCompleted() {
completed = !completed;
}
}
ここで一緒にUTを書いていきます。まずテスト用のパッケージをインストールします。
$ flutter pub add dev:test
次に、テスト用のファイルを test/todo/todo_test.dart として作成します。flutter create した時点で、libディレクトリと同階層にtestディレクトリも作成されており、widget_test.dart というファイルがあるはずです。このファイルは消してしまいます。
import 'package:flutter_test/flutter_test.dart';
import 'package:todoapp/todo/todo.dart';
void main() {
group('Todo', () {
test('Todo instance should be created', () {
final todo = Todo(title: 'testtitle');
expect(todo.completed, false);
});
test('Todo completed should be toggled', () {
final todo = Todo(title: 'testtitle');
expect(todo.completed, false);
todo.toggleCompleted();
expect(todo.completed, true);
});
});
}
上記のテストを実行し通ることを確認します。
TodoServiceの作成
今回はデータの永続化は取り扱いませんが、通常必ず作成することになるので、今後の拡張を意識してデータの永続化処理を担うクラスだけ一応作成しておきます。本来であればここでSQLiteやBEのAPIを呼んで、Futureとして取り扱うことになります。
※ こういったクラスには xxxRepository という名前をつけることが多いようですが、クライアントサイドの実装にDDDを導入するつもりはなく、Repositoryというと個人的にはどうしてもDDDの印象が強く出てしまう気がするので、xxxService としておきます。
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:todoapp/todo/todo.dart';
// TODO: implement perpetuation logic when needed.
class TodoService {
List<Todo> getAll() {
return [];
}
void add(Todo todo) async {}
void toggle(int number) async {}
void delete(Todo todo) async {}
}
TodoChangeNotifierの作成
続いて、先ほど作成した Todo の状態変化を notify する役割を担う TodoChangeNotifier を作成します。これも TodoProvider という名前をつける場合が多いようですが、より直感的な名前で作成しておきます。
import 'dart:collection';
import 'package:flutter/cupertino.dart';
import 'package:todoapp/todo/todo.dart';
import 'package:todoapp/todo/todo_service.dart';
class TodoChangeNotifier with ChangeNotifier {
final TodoService _todoService;
TodoChangeNotifier(this._todoService);
final List<Todo> _tasks = [];
UnmodifiableListView<Todo> getAll() {
_todoService.getAll();
return UnmodifiableListView(_tasks);
}
void add(Todo newTask) {
_todoService.add(newTask);
_tasks.add(newTask);
notifyListeners();
}
void toggle(Todo task) {
final taskIndex = _tasks.indexOf(task);
_tasks[taskIndex].toggleCompleted();
_todoService.toggle(taskIndex);
notifyListeners();
}
void delete(Todo task) {
_tasks.remove(task);
_todoService.delete(task);
notifyListeners();
}
}
※ todoService は一応呼び出してますが、何もしていません。
TodoChangeNotifier のUTも書いておきます。todoServiceをモック化するために、mocktail を導入しておきます。
$ flutter pub add mocktail
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:todoapp/todo/todo.dart';
import 'package:todoapp/todo/todo_change_notifier.dart';
import 'package:todoapp/todo/todo_service.dart';
class MockTodoService extends Mock implements TodoService {}
void main() {
late TodoChangeNotifier sut_todoNotifier;
late MockTodoService mockTodoService;
setUp(() {
mockTodoService = MockTodoService();
sut_todoNotifier = TodoChangeNotifier(mockTodoService);
});
group('getAll', () {
test('Should check getAll values are correct', () {
final List<Todo> expected = [];
when(() => mockTodoService.getAll()).thenAnswer((_) => expected);
final todoList = sut_todoNotifier.getAll();
expect(todoList, expected);
verify(() => mockTodoService.getAll()).called(1);
});
});
group('add', () {
test('Should check add works expectedly', () {
final Todo newTask = Todo(title: 'newTodo');
when(() => mockTodoService.add(newTask)).thenReturn(null);
expect(() => sut_todoNotifier.add(newTask), returnsNormally);
verify(() => mockTodoService.add(newTask)).called(1);
});
});
group('toggle', () {
test('Should check toggle works expectedly', () {
// add new Task
final Todo newTask = Todo(title: 'newTodo');
when(() => mockTodoService.add(newTask)).thenReturn(null);
expect(() => sut_todoNotifier.add(newTask), returnsNormally);
verify(() => mockTodoService.add(newTask)).called(1);
// toggle task
when(() => mockTodoService.toggle(0)).thenReturn(null);
expect(() => sut_todoNotifier.toggle(newTask), returnsNormally);
});
});
group('delete', () {
test('Should check delete works expectedly', () {
// add new Task
final Todo newTask = Todo(title: 'newTodo');
when(() => mockTodoService.add(newTask)).thenReturn(null);
expect(() => sut_todoNotifier.add(newTask), returnsNormally);
verify(() => mockTodoService.add(newTask)).called(1);
// delete task
when(() => mockTodoService.delete(newTask)).thenReturn(null);
expect(() => sut_todoNotifier.delete(newTask), returnsNormally);
});
});
}
TodoAction (widget) の作成
TodoAction (TodoList部分を表現するwidget) を実装します。 Provider.of<TodoChangeNotifier>(context)
を使ってTodoChangeNotifierを呼び出しています。
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:todoapp/todo/todo_change_notifier.dart';
class TodoAction extends StatelessWidget {
const TodoAction({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final task = Provider.of<TodoChangeNotifier>(context);
return ListView.builder(
itemCount: task.getAll().length,
itemBuilder: ((context, index) => ListTile(
leading: Checkbox(
value: task.getAll()[index].completed,
onChanged: ((_) => task.toggle(task.getAll()[index])),
),
title: Text(task.getAll()[index].title),
trailing: IconButton(
onPressed: () {
task.delete(task.getAll()[index]);
},
icon: const Icon(Icons.delete)),
)));
}
}
※ Widget tests や Integration tests についてはまた別の機会でまとめます。
ここで Consumer Widget じゃないんだ? と思って調べてみました。要するに、Consumer Widget を使うとUIを再構築する部分を限定することができるので、限定的な部分だけ描画を変更すれば良い場合に積極的に用いると良いようです。
https://stackoverflow.com/questions/58774301/when-to-use-provider-ofx-vs-consumerx-in-flutter
TodoPageの作成
pages ではなく screens ディレクトリ内に配置することが多いようですが、個人的には pages に置いた方が直感的だと思うのでそうします。
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:todoapp/todo/todo_change_notifier.dart';
import '../todo/todo.dart';
import '../widgets/todo_action.dart';
class TodoPage extends StatefulWidget {
const TodoPage({Key? key}) : super(key: key);
@override
_TodoPageState createState() => _TodoPageState();
}
class _TodoPageState extends State<TodoPage> {
final _textFieldController = TextEditingController();
String newTask = '';
@override
void initState() {
super.initState();
_textFieldController.addListener(() {
newTask = _textFieldController.text;
});
}
@override
void dispose() {
_textFieldController.dispose();
super.dispose();
}
void _submit() {
Provider.of<TodoChangeNotifier>(context, listen: false)
.add(Todo(title: newTask, completed: false));
Navigator.pop(context);
_textFieldController.clear();
}
@override
Widget build(BuildContext context) {
Future<void> _showAddTextDialog() async {
return showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: const Text("Add a new Task"),
content: TextField(
autofocus: true,
controller: _textFieldController,
decoration: const InputDecoration(hintText: "Add New Task"),
onSubmitted: (_) => _submit(),
),
actions: [
ElevatedButton(
onPressed: _submit,
child: const Text("Submit"),
style: ElevatedButton.styleFrom(
minimumSize: const Size(120, 40)),
)
],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
actionsAlignment: MainAxisAlignment.center,
);
});
}
return Scaffold(
appBar: AppBar(
title: const Text("TODO App"),
),
body: const TodoAction(),
floatingActionButton: FloatingActionButton(
onPressed: (() {
_showAddTextDialog();
}),
child: const Icon(Icons.add),
tooltip: "Add a TODO",
),
);
}
}
ここでは StatefulWidget を使って TodoPageState を管理し、その中で「テキスト入力フォーム」と「新規作成されるタスクのタイトル」を管理しています。前回「Flutter の Provider を公式の説明を読んで爆速で理解する」の記事にまとめた「ephemeral state と app state の違い」の話ですが、確かに「テキスト入力フォーム」と「新規作成されるタスクのタイトル」は本ページだけで扱えれば良いStateに該当するので、StatefulWidget で管理するのが良いですね。
main.dart の作成
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:todoapp/pages/todo_page.dart';
import 'package:todoapp/todo/todo_change_notifier.dart';
import 'package:todoapp/todo/todo_service.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
// This widget is the root of your application.
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: ((context) => TodoChangeNotifier(TodoService())),
child: MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const TodoPage(),
));
}
}
あとは TodoPage を呼び出して、それを ChangeNotifierProvider で包んであげれば完了です。
最後に
以下の公式の説明を読むとまだ色々と書き方があるようですし、これで完全に理解したとは言い難いですが、少なくとも最低限使い始められる状態にはなったかなと思います。引き続き学びつつ作っていきます。