Zubora Code

Flutter Provider を TODO List を実装して理解する

Flutter Provider を TODO List を実装して理解します。

Published: 18 September, 2023
Revised: 18 September, 2023

概要

前回は「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

https://blog.dalt.me/1815


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 で包んであげれば完了です。


最後に

以下の公式の説明を読むとまだ色々と書き方があるようですし、これで完全に理解したとは言い難いですが、少なくとも最低限使い始められる状態にはなったかなと思います。引き続き学びつつ作っていきます。

https://pub.dev/packages/provider

Toshimitsu Kugimoto

Software Engineer

仕事では決済やメディアのWebやスマホアプリのBE開発、WebのFE開発をやっています。 JavaとTypeScriptをよく使います。プライベートではFlutterでのアプリ開発にも挑戦中です。