Flutter で TODO アプリ📝 を作ってみた！

# はじめに

こんにちは！
モバイルエンジニアの[やまたつ](https://twitter.com/yamatatsu109_ja)と申します！

今回は TODOアプリ📝 を作成してみました！
この記事が誰かの役に立てば幸いです。

※明らかに間違っていたり、非推奨の実装などありましたら、アドバイスいただけると嬉しいです🙇‍♂️

## 作成したTODOアプリ📝 の GIF

![TODO](https://user-images.githubusercontent.com/43805056/79782552-454acc00-837a-11ea-8445-78bfefc3c770.gif)

## GitHub

以下にソースコードを置いています。
[YamaTatsu10969/todo_app_yamatatsu](https://github.com/YamaTatsu10969/todo_app_yamatatsu)

ブランチは、 `textField` と `textFormField` を使ったものがあります。
本記事で紹介するのは　`textField` のプロジェクトになります！
参考になりましたら、スターを付けてもらえるとめちゃくちゃ嬉しいです😆


## 機能
最低限の機能だけを実装しています。

- タスクの追加
- タスクの削除
- タスクの編集
- タスクの読み込み
- 簡単な validate （Nameが空の時だけ）


## 環境
- Dart： 2.7.0
- Flutter： 1.12.13+hotfix.8 • channel stable 
- provider: 4.0.4

## 構成

### ファイル構成
<img width="329" alt="Screen Shot 2020-04-21 at 2.20.17.png" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/326574/12a39ced-fc77-f840-9823-1409b386f5f9.png">

### State管理
`Provider` + `ChangeNotifier` で管理しています。
以下のドキュメントが参考になります。

[参考：Simple app state management - Flutter](https://flutter.dev/docs/development/data-and-backend/state-mgmt/simple#changenotifierprovider)

私はいくつかの State管理を試しましたが、 今までの私の経験からは `Provider` + `ChangeNotifier` が一番しっくりきました。（BLoC は学習コストが高いと思いました）
以下の mono さんの記事にもある通り、オススメの管理方法です。

> BLoC と 「provider + ChangeNotifier」 やその他のパターンの優劣はケースバイケースであり一概には言えないが、「provider + ChangeNotifier」が「StatefulWidgetにベタ書き」の次のステップとしてのスタンダードであり、かつ適切に扱えばそのパターンで大抵の要件はスムーズにこなせる(という前提で、好みや要件に応じて他のパターンの採用・併用もありという考え)。

[参考：FlutterのBLoC(Business Logic Component)のライフサイクルを正確に管理して提供するbloc_providerパッケージの解説](https://medium.com/flutter-jp/bloc-provider-70e869b11b2f)

State管理フローチャートで、管理方法を決めるのもオススメです。

<blockquote class="twitter-tweet" data-theme="dark"><p lang="ja" dir="ltr">Flutter状態管理フローチャートを書いた(　´･‿･｀)<a href="https://t.co/YS9wqexLtQ">https://t.co/YS9wqexLtQ</a> 周りを勉強する際に見通しが良くなるはず(　´･‿･｀) <a href="https://t.co/zCczrCEZJ6">pic.twitter.com/zCczrCEZJ6</a></p>&mdash; mono 🎯 @自宅 💙 (@_mono) <a href="https://twitter.com/_mono/status/1170516947970097152?ref_src=twsrc%5Etfw">September 8, 2019</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>

## 各ファイルの説明

`initialRoute ` と　`route` で遷移を管理しています。
`TaskViewModel (ChangeNotifierProvider)` で管理しているプロパティは全ての画面で使いたいので、最上階層に配置しています。

```dart:main.dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:todo_app_yamatatsu/screen/add_task_screen/add_task_screen.dart';
import 'package:todo_app_yamatatsu/screen/task_screen/task_screen.dart';
import 'package:todo_app_yamatatsu/view_model/task_view_model.dart';

void main() {
  print('Welcome Yamatatsu Todo App !!!');
  runApp(
    ChangeNotifierProvider(
      create: (context) => TaskViewModel(),
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Yamatatsu TODO',
      theme: ThemeData(
        primarySwatch: Colors.lightGreen,
      ),
      initialRoute: TaskScreen.id,
      routes: {
        TaskScreen.id: (context) => TaskScreen(),
        AddTaskScreen.id: (context) => AddTaskScreen(),
      },
    );
  }
}
```

単純なモデルです。

```dart:task.dart

class Task {
  String name;
  String memo;
  bool isDone;
  final DateTime createdAt;
  DateTime updatedAt;

  Task(
      {this.name,
      this.memo,
      this.isDone = false,
      this.createdAt,
      this.updatedAt});
}
```


`ChangeNotifier` を継承したクラスです。
このクラスで表示するタスクや、追加するタスク、編集するタスクを管理しています。
状態変更をUIに反映させたい時には、必ず `notifyListeners();` を呼びましょう。

タスクの編集時に初期値を入れたかったので、`TextEditingController` を使っています。
初期値を入れない場合は、 TextField → onChanged で変更を管理するだけで大丈夫そうです。
textFormField には initialValue というプロパティがあるのでそれに入れると初期値を設定できます（textFieldにも欲しい。。。）

textFormField の実装も GitHub で行っているので、見たい方は参考にしてください（[todo_app_yamatatsu at textFormField](https://github.com/YamaTatsu10969/todo_app_yamatatsu/tree/textFormField)）

```dart:TaskViewModel.dart

import 'dart:collection';

import 'package:flutter/material.dart';
import 'package:todo_app_yamatatsu/model/task.dart';

class TaskViewModel extends ChangeNotifier {
  String get editingName => nameController.text;
  String get editingMemo => memoController.text;
  TextEditingController nameController = TextEditingController();
  TextEditingController memoController = TextEditingController();
  String _strValidateName = '';
  String get strValidateName => _strValidateName;
  bool _validateName = false;
  bool get validateName => _validateName;

  List<Task> _tasks = [];
  UnmodifiableListView<Task> get tasks {
    return UnmodifiableListView(_tasks);
  }

  bool validateTaskName() {
    if (editingName.isEmpty) {
      _strValidateName = 'Please input something.';
      notifyListeners();
      return false;
    } else {
      _strValidateName = '';
      _validateName = false;
      return true;
    }
  }

  void setValidateName(bool value) {
    _validateName = value;
  }

  void updateValidateName() {
    if (validateName) {
      validateTaskName();
      notifyListeners();
    }
  }

  void addTask() {
    final newTask = Task(
      name: nameController.text,
      memo: memoController.text,
      createdAt: DateTime.now(),
      updatedAt: DateTime.now(),
    );
    _tasks.add(newTask);
    clear();
  }

  void updateTask(Task updateTask) {
    var updateIndex = _tasks.indexWhere((task) {
      return task.createdAt == updateTask.createdAt;
    });
    updateTask.name = nameController.text;
    updateTask.memo = memoController.text;
    updateTask.updatedAt = DateTime.now();
    _tasks[updateIndex] = updateTask;
    clear();
  }

  void deleteTask(int index) {
    _tasks.removeAt(index);
    notifyListeners();
  }

  void toggleDone(int index, bool isDone) {
    var task = _tasks[index];
    task.isDone = isDone;
    _tasks[index] = task;
    notifyListeners();
  }

  void clear() {
    nameController.clear();
    memoController.clear();
    _validateName = false;
    notifyListeners();
  }
}

```

AppBar の右に、次の画面に遷移するボタンを配置しています。
`actions` は右に配置されて、`leading` は左に配置されます。
ListView はファイルを別にして切り出しています。

```dart:task_screen.dart
import 'package:flutter/material.dart';
import 'package:todo_app_yamatatsu/screen/add_task_screen/add_task_screen.dart';
import 'package:todo_app_yamatatsu/screen/task_screen/task_list_view.dart';

class TaskScreen extends StatelessWidget {
  static String id = 'task_screen';
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Task List'),
        actions: [
          GestureDetector(
            onTap: () {
              Navigator.pushNamed(context, AddTaskScreen.id);
            },
            child: Padding(
              padding: const EdgeInsets.all(8.0),
              child: Icon(Icons.add),
            ),
          ),
        ],
      ),
      body: Container(
        child: TaskListView(),
      ),
    );
  }
}
```

`Consumer<TaskViewModel>(builder: (context, taskViewModel, _) {` を使って、
stateの変更を受け取っています。
`taskViewModel` から値を受け取ったり、メソッドを使用したりしています。

`taskViewModel.tasks` が空の場合は、EmptyView を配置しているようにしています。
Flutter でとても便利だなと思うことの一つは、エラーの場合や空の場合に、通常とは異なる View（今回は EmptyView） を配置する実装がとても簡単なことです。
（Swift で tableView を実装していた時に、 `DZNEmptyDataSet` と言うライブラリを良く使っていました） 


今回は `Dismissible` を使用しており、item を横にスライドさせた時にアニメーションが走ります。
今回は、タスクを DONE にしたり、削除したりできるようにしています。
以下に使い方が載っています。
[参考： Dismissible (Flutter Widget of the Week) - YouTube](https://www.youtube.com/watch?v=iEMgjrfuc58)

```dart:task_list_view.dart

import 'dart:ui';

import 'package:flutter/material.dart';
import 'package:flutter/painting.dart';
import 'package:flutter/widgets.dart';
import 'package:provider/provider.dart';
import 'package:todo_app_yamatatsu/screen/add_task_screen/add_task_screen.dart';
import 'package:todo_app_yamatatsu/screen/task_screen/task_item.dart';
import 'package:todo_app_yamatatsu/view_model/task_view_model.dart';

class TaskListView extends StatelessWidget {
  const TaskListView({
    Key key,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Consumer<TaskViewModel>(builder: (context, taskViewModel, _) {
      if (taskViewModel.tasks.isEmpty) {
        return _emptyView();
      }
      return ListView.separated(
          itemBuilder: (context, index) {
            var task = taskViewModel.tasks[index];
            return Dismissible(
              key: UniqueKey(),
              onDismissed: (direction) {
                if (direction == DismissDirection.endToStart) {
                  taskViewModel.deleteTask(index);
                } else {
                  taskViewModel.toggleDone(index, true);
                }
              },
              background: _buildDismissibleBackgroundContainer(false),
              secondaryBackground: _buildDismissibleBackgroundContainer(true),
              child: TaskItem(
                task: task,
                onTap: () {
                  Navigator.of(context).push(
                    MaterialPageRoute(builder: (context) {
                      var task = taskViewModel.tasks[index];
                      taskViewModel.nameController.text = task.name;
                      taskViewModel.memoController.text = task.memo;
                      return AddTaskScreen(editTask: task);
                    }),
                  );
                },
                toggleDone: (value) {
                  taskViewModel.toggleDone(index, value);
                },
              ),
            );
          },
          separatorBuilder: (_, __) => Divider(),
          itemCount: taskViewModel.tasks.length);
    });
  }

  Container _buildDismissibleBackgroundContainer(bool isSecond) {
    return Container(
      color: isSecond ? Colors.red : Colors.green,
      alignment: isSecond ? Alignment.centerRight : Alignment.centerLeft,
      child: Padding(
        padding: const EdgeInsets.all(8),
        child: Text(
          isSecond ? 'Delete' : 'Done',
          textAlign: TextAlign.center,
          style: TextStyle(
              fontSize: 18, fontWeight: FontWeight.bold, color: Colors.white),
        ),
      ),
    );
  }

  Widget _emptyView() {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: <Widget>[
          Text("You don't have a task!!!"),
          SizedBox(height: 16),
          Text(
            'Complete!!!',
            style: TextStyle(
              fontWeight: FontWeight.bold,
              fontSize: 32,
            ),
          ),
        ],
      ),
    );
  }
}

```


item ではプロパティを持たせて、コンストラクタで受け取るようにしていて、DIを意識しています。
`final Function(bool) toggleDone;` を Function で渡すことで、 
`CheckBox`　の `onChanged` の値を受け取ることができます。

`final Function(bool) toggleDone` は、 `final Function toggleDone` とも書けるのですが、これはお勧めしません。
型が dynamic になってしまい、予期せぬエラーにつながります。
これを防ぐには、 `analysis_options.yaml` を配置して、警告を出すようにすると良いです。
こちらに詳しく載っているので Flutter を勉強していく方はぜひ一読すると良いと思います。

[Dart/Flutter の静的解析強化のススメ - Flutter 🇯🇵 - Medium](https://medium.com/flutter-jp/analysis-b8dbb19d3978) 


Tips ですが、final でプロパティを記述し終わった後に、option + enter を押すと、
コンストラクタが自動生成されます（Android Studioだけかな)


```dart:task_item.dart
import 'package:flutter/material.dart';
import 'package:todo_app_yamatatsu/model/task.dart';

class TaskItem extends StatelessWidget {
  final Task task;
  final VoidCallback onTap;
  final Function(bool) toggleDone;

  const TaskItem(
      {Key key,
      @required this.onTap,
      @required this.task,
      @required this.toggleDone})
      : super(key: key);

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      behavior: HitTestBehavior.opaque,
      onTap: onTap,
      child: Padding(
        padding: const EdgeInsets.all(8.0).copyWith(left: 20),
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: <Widget>[
            Flexible(
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: <Widget>[
                  Text(
                    task.name,
                    style: Theme.of(context)
                        .textTheme
                        .headline
                        .copyWith(fontWeight: FontWeight.bold),
                  ),
                  (task.memo.isEmpty)
                      ? Container()
                      : Column(
                          children: <Widget>[
                            SizedBox(height: 4),
                            Text(
                              task.memo,
                              maxLines: 3,
                              overflow: TextOverflow.ellipsis,
                              style: Theme.of(context).textTheme.body1,
                            ),
                          ],
                        ),
                ],
              ),
            ),
            Checkbox(
              value: task.isDone,
              onChanged: (value) {
                print(value);
                toggleDone(value);
              },
              activeColor: Colors.lightGreen,
            )
          ],
        ),
      ),
    );
  }
}
```

`WillPopScope` を使って back ボタンをハンドリングしています。`return false` にすると戻れないようにすることができます。
`Consumer` で viewModel のメソッドやプロパティを参照しています。
1ファイルが大きくなっているのでファイルを分けてもよかったかなと思います。

```dart:add_task_screen.dart
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:todo_app_yamatatsu/model/task.dart';
import 'package:todo_app_yamatatsu/view_model/task_view_model.dart';

class AddTaskScreen extends StatelessWidget {
  static String id = 'add_task_screen';
  final Task editTask;
  AddTaskScreen({Key key, this.editTask}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return Consumer<TaskViewModel>(
      builder: (context, viewModel, _) {
        return WillPopScope(
          onWillPop: () async {
            viewModel.clear();
            return true;
          },
          child: Scaffold(
            appBar: AppBar(
              title: Text(_isEdit() ? 'Save Task' : 'Add Task'),
            ),
            body: ListView(
              children: <Widget>[
                _buildInputField(
                  context,
                  title: 'Name',
                  textEditingController: viewModel.nameController,
                  errorText:
                      viewModel.validateName ? viewModel.strValidateName : null,
                  didChanged: (_) {
                    viewModel.updateValidateName();
                  },
                ),
                _buildInputField(
                  context,
                  title: 'Memo',
                  textEditingController: viewModel.memoController,
                  errorText: null,
                ),
                _buildAddButton(context),
              ],
            ),
          ),
        );
      },
    );
  }

  bool _isEdit() {
    return editTask != null;
  }

  void tapAddButton(BuildContext context) {
    final viewModel = Provider.of<TaskViewModel>(context, listen: false);
    viewModel.setValidateName(true);
    if (viewModel.validateTaskName()) {
      _isEdit() ? viewModel.updateTask(editTask) : viewModel.addTask();
      Navigator.of(context).pop();
    }
  }

  Widget _buildInputField(BuildContext context,
      {String title,
      TextEditingController textEditingController,
      String errorText,
      Function(String) didChanged}) {
    return Padding(
      padding: const EdgeInsets.all(20),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Text(
            title,
            style: Theme.of(context).textTheme.subtitle,
          ),
          TextField(
            controller: textEditingController,
            decoration: InputDecoration(errorText: errorText),
            onChanged: (value) {
              didChanged(value);
            },
          ),
        ],
      ),
    );
  }

  Widget _buildAddButton(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(20),
      child: RaisedButton(
        onPressed: () => tapAddButton(context),
        color: Theme.of(context).primaryColor,
        padding: const EdgeInsets.all(20),
        child: Center(
          child: Text(
            _isEdit() ? 'Save' : 'Add',
            style:
                Theme.of(context).textTheme.title.copyWith(color: Colors.white),
          ),
        ),
      ),
    );
  }
}

```


## まとめ

以上が TODOアプリの説明になります！
このアプリを作る中でも勉強になるところがたくさんありました。
TODO アプリは言語の学習に最適だと言われる理由がわかった気がします。

Swift を学習していた時も TODOアプリを作って、わからないところを質問して、理解を深めていきました。
その後に自分のアプリを作成して、エンジニアとして成長できたと思っています！
この TODOアプリが誰かの役に立つととても嬉しいです。

なにかありましたら Twitter にてご連絡ください 。
 [Twitter: やまたつ🐥](https://twitter.com/yamatatsu109_ja) 

読んでいただき、ありがとうございました (^^)





## おまけ： Flutter の学習に良いコンテンツ

私が実際に読んで良かったと思っているコンテンツです！


[Flutterの効率良い学び方 - Flutter 🇯🇵 - Medium](https://medium.com/flutter-jp/flutter-learning-c5640c5f05b9)

[FlutterとFirebaseで開発した 英語の瞬間翻訳トレーニングアプリ Lala の技術 - Qiita](https://qiita.com/hukusuke1007/items/318875bb11c23572d46f)

[Flutter製アプリ「筋トレ ビフォーアフター」を元にFlutterと個人開発を学ぶ - Qiita](https://qiita.com/takashi-i/items/29f26669290eed5a4282)

[Udemy: The Complete 2020 Flutter Development Bootcamp with Dart | Udemy](https://www.udemy.com/course/flutter-bootcamp-with-dart/)

以下は Dartの言語についてですので、一通り学習が済んだら読んでみると面白いかもしれません。
[Effective Dartまとめ - Qiita](https://qiita.com/mkosuke/items/42c19d7edbf111f7fb71)