Riverpod, StateNotifier, freezed, MoorでTodoアプリの作成

# はじめに
今回、Flutterにおける状態管理の手法の一つであるRiverpod, StateNotifier, Freezedと
FlutterのローカルDBのMoorを使用して簡単なTodoアプリを作成しました。

Flutterを始めてまだ数ヶ月の初学者ですので内容に不十分な点があるかもしれませんが、
アウトプットも兼ねて記事を書くことにしました。


　

※Riverpod, StateNotifier, freezed, Moorのそれぞれの詳しい説明は本記事では割愛してます。
※現在Flutter2.0.5が最新版ですが、今回のTodoアプリではバージョン1.22.6を使用しています。
※Riverpodも最新版ではありませんが、以下を参照すれば対応可能だと思います。

https://riverpod.dev/docs/migration/0.13.0_to_0.14.0

# インストール
まずは今回のTodoアプリを作成するのに必要なパッケージをpubspec.yamlに追記します。

Moorについては以下を参照し、パッケージを追加しました。

https://moor.simonbinder.eu/docs/getting-started/

```pubspec.yaml
dependencies:
  flutter:
    sdk: flutter

  //以下はRiverpod, StateNotifier, freezedで使用。
  freezed_annotation: ^0.11.0
  flutter_riverpod: ^0.12.1
  flutter_state_notifier: ^0.6.1

  //以下Moorで使用。
  moor: ^3.4.0
  sqlite3_flutter_libs: ^0.4.0
  path_provider: ^1.6.28
  path: ^1.7.0

  //View側をいい感じにするために使用。
  flutter_form_builder: ^4.2.0
  date_time_picker: ^1.1.1
  flutter_slidable: ^0.5.7

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^1.10.0
  freezed: ^0.11.2
  moor_generator: ^3.3.1
```

`pub get`を実行します。

# テーブル定義

MoorのGetting started参考に以下のように書きました。

```db.dart
import 'package:moor/moor.dart';

part 'db.g.dart';

class TodoItem extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get title => text().withLength(min: 1, max: 24)();
  TextColumn get sentence => text().withLength(min: 1, max: 100)();
  TextColumn get createdAt => text()();
  TextColumn get limitDate => text()();
}

@UseMoor(tables: [TodoItem])
class MyDatabase  {

}
```

ここで、一旦以下のコマンドを実行します。

```terminal
$ flutter packages pub run build_runner build
```

修正を行った場合など、2回目以降`build_runner`を走らせる場合は、

```terminal
$ flutter packages pub run build_runner build --delete-conflicting-outputs
```
で実行します。

`db.g.dart`が作成されますので、ここから先程の`db.dart`に追記をしていきます。

```db.dart
import 'dart:io';

import 'package:moor/ffi.dart';
import 'package:moor/moor.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';

part 'db.g.dart';

class TodoItem extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get title => text().withLength(min: 1, max: 24)();
  TextColumn get sentence => text().withLength(min: 1, max: 100)();
  TextColumn get createdAt => text()();
  TextColumn get limitDate => text()();
}

LazyDatabase _openConnection() {
  return LazyDatabase(() async {
    final dbFolder = await getApplicationDocumentsDirectory();
    final file = File(p.join(dbFolder.path, 'db.sqlite'));
    return VmDatabase(file);
  });
}

//以下主な変更部分
@UseMoor(tables: [TodoItem])
class MyDatabase extends _$MyDatabase {
  static MyDatabase _instance;

  static MyDatabase getInstance() {
    //シングルトン対応
    if (_instance == null) {
      _instance = new MyDatabase();
    }
    return _instance;
  }

  MyDatabase() : super(_openConnection());

  @override
  int get schemaVersion => 1;

  //全てのデータ取得
  Future<List<TodoItemData>> readAllTodoData() => select(todoItem).get();

  //追加
  Future writeTodo(TodoItemData data) => into(todoItem).insert(data);

  //更新
  Future updateTodo(TodoItemData data) => update(todoItem).replace(data);

  //削除
  Future deleteTodo(int id) =>
      (delete(todoItem)..where((it) => it.id.equals(id))).go();
}
```

MoorのGetting startedを参考にしていけば、なんとかここまでは書けました。

　
~~ちなみにMoorではなくShared_Preferencesを使用してもいいと思います。
（Shared_Preferencesのほうが参考文献が多いので始めての方はやりやすいかもしれません。）~~
自分の理解が乏しく、上記のようなことを記載していましたが、Moorと同じようには使えるものではありません。全くの別物として考えてください。

↓Shared_Preferences

https://pub.dev/packages/shared_preferences

```dart
  //全てのデータ取得
  Future<List<TodoItemData>> readAllTodoData() => select(todoItem).get();

  //追加
  Future writeTodo(TodoItemData data) => into(todoItem).insert(data);

  //更新
  Future updateTodo(TodoItemData data) => update(todoItem).replace(data);

  //削除
  Future deleteTodo(int id) =>
      (delete(todoItem)..where((it) => it.id.equals(id))).go();
```
`get`でデータ取得

`insert`で追加

`replace`で更新

`go`で削除

となるようです。

# Repository
先程の`db_dart`とやり取りを行うRepositoryです。

```todo_repository.dart
class MoorRepository {
  MoorRepository() {
    _database = MyDatabase.getInstance();
  }

  MyDatabase _database;

  //全てのデータ取得
  Future<List<TodoItemData>> readAllTodoItems() async =>
      await _database.readAllTodoData();

  //追加
  Future writeTodoData(TodoItemData data) async =>
      await _database.writeTodo(data);

  //更新
  Future deleteTodoData(int id) async => await _database.deleteTodo(id);

  //削除
  Future updateTodoData(TodoItemData data) async =>
      await _database.updateTodo(data);
}
```

# State
では次にStateを作ります。
こちらではfreezedを使用しています。

```todo_state.dart
import 'package:.../moor/db.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'todo_state.freezed.dart';

@freezed
abstract class TodoState with _$TodoState {
  const factory TodoState({
    @Default(false) bool isLoading,
    @Default(false) bool isReadyData,
    List<TodoItemData> todoItems,
  }) = _TodoState;
}
```

エラーが出てる状態ですが、ここまで書いて先程の

```terminal
$ flutter packages pub run build_runner build
```
を実行すると`todo_state.freezed.dart`が自動生成され、エラーが消えます。

　


`List<TodoItemData> todoItems`の部分ですが、下記の`db.dart`で定義した部分が、

```db.dart
class TodoItem extends Table {
  IntColumn get id => integer().autoIncrement()();
  TextColumn get title => text().withLength(min: 1, max: 24)();
  TextColumn get sentence => text().withLength(min: 1, max: 100)();
  TextColumn get createdAt => text()();
  TextColumn get limitDate => text()();
}
```
build_runnerによって生成された`db.g.dart`内で以下のようになっており、
この`TodoItemData`を使い、`List<TodoItemData> todoItems`とします。

```db.g.dart
class TodoItemData extends DataClass implements Insertable<TodoItemData> {
  final int id;
  final String title;
  final String sentence;
  final String createdAt;
  final String limitDate;
  TodoItemData(
      {@required this.id,
      @required this.title,
      @required this.sentence,
      @required this.createdAt,
      @required this.limitDate});

//以下省略
```

# StateNotifier
では次にStateNotifierの部分を書いていきます。

```todo_state_notifier.dart
import 'package:.../moor/db.dart';
import 'package:.../moor/repository/moor_repository.dart';
import 'package:.../moor/state/todo_state.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';


class TodoStateNotifier extends StateNotifier<TodoState> {
  TodoStateNotifier() : super(TodoState()) {
    readData(); //最初に実行される。
  }

  MoorRepository _repository = MoorRepository();

  //書き込み処理部分
  writeData(TodoItemData data) async {
    state = state.copyWith(isLoading: true);
    await _repository.writeTodoData(data);
    readData();
  }

  //削除処理部分
  deleteData(TodoItemData data) async {
    state = state.copyWith(isLoading: true);
    await _repository.deleteTodoData(data.id);
    readData();
  }


  //データ読み込み処理
  readData() async {
    state = state.copyWith(isLoading: true);

    final todoItems = await _repository.readAllTodoItems();

    state = state.copyWith(
      isLoading: false,
      isReadyData: true,
      todoItems: todoItems,
    );
  }
}
```
先程作成した`todo_repository.dart`とやり取りを行うクラスです。
`state`の中身はこちらも先程作成した`todo_state.dart`となってます。


# View
ここからいよいよ画面のコードを書いていきます。
ここに関しても詳しい解説は割愛します。


```todo_screen.dart
import 'package:.../moor/db.dart';
import 'package:.../moor/todo_state_notifier.dart';
import 'package:date_time_picker/date_time_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_slidable/flutter_slidable.dart';

//ここでtodo_state_notifierを呼び出す。
final toDoStateNotifierProvider =
    StateNotifierProvider((ref) => TodoStateNotifier());

//ConsumerWidgetで囲む。
class TodoScreen extends ConsumerWidget {
  final _formKey = GlobalKey<FormState>();

  @override
  Widget build(BuildContext context, ScopedReader watch) {
 
    //stateを呼び出す。
    final state = watch(toDoStateNotifierProvider.state);
    TodoItemData itemData;
    return Scaffold(
      body: SafeArea(
        child: Stack(
          children: [
            Container(
              child: Center(
                child: state.isReadyData
                    ? _createBody(state.todoItems)
                    : Container(),
              ),
            ),
            state.isLoading
                ? Container(
                    color: Color(0x88000000),
                    child: Center(
                      child: CircularProgressIndicator(),
                    ),
                  )
                : Container(),
          ],
        ),
      ),
      floatingActionButton: _createFloatingActionButton(context, itemData),
    );
  }

  Widget _createBody(List<TodoItemData> itemData) {
    return ListView.builder(
      itemCount: itemData.length,
      itemBuilder: (context, index) {
        final data = itemData[index];
        return _buildTodoItem(context, data);
      },
    );
  }

  Widget _buildTodoItem(BuildContext context, TodoItemData data) {
    return Slidable(
      actions: [
        IconSlideAction(
          caption: 'delete',
          icon: Icons.delete,
          color: Colors.red,
          onTap: () {
            //メソッドの呼び出しはcontext.readを使う。
            context.read(toDoStateNotifierProvider).deleteData(data);
          },
        )
      ],
      actionPane: SlidableDrawerActionPane(),
      child: Container(
        margin: EdgeInsets.only(top: 5),
        child: ListTile(
          title: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                'タイトル：${data.title}',
                style: TextStyle(fontSize: 15),
              ),
              SizedBox(height: 5),
              Text(
                data.sentence,
                style: TextStyle(fontSize: 20),
              ),
            ],
          ),
          subtitle: Column(
            children: [
              Row(
                mainAxisAlignment: MainAxisAlignment.spaceEvenly,
                children: [
                  Text('作成日：${data.createdAt.toString()}'),
                  Text(
                    '期日：${data.limitDate}',
                    style: TextStyle(color: Colors.red[400]),
                  ),
                ],
              ),
              Divider(
                color: Colors.grey,
              )
            ],
          ),
        ),
      ),
    );
  }

  Widget _createFloatingActionButton(
      BuildContext context, TodoItemData itemData) {
    return FloatingActionButton(
      child: Icon(Icons.edit),
      onPressed: () {
        _showInputDialog(context, itemData);
      },
    );
  }

  Future _showInputDialog(BuildContext context, TodoItemData itemData) {
    final today = DateTime.now();
    TextEditingController _titleController = TextEditingController();
    TextEditingController _sentenceController = TextEditingController();
    TextEditingController _limitDateController = TextEditingController();

    return showDialog(
      context: context,
      builder: (_) {
        return AlertDialog(
          content: Form(
            key: _formKey,
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                TextFormField(
                  controller: _titleController,
                  decoration: InputDecoration(
                    hintText: 'タイトルを入力してください',
                    icon: Icon(Icons.text_fields),
                  ),
                  textInputAction: TextInputAction.next,
                  validator: (value) {
                    if (value.isEmpty) {
                      return '未入力です';
                    }
                    return null;
                  },
                ),
                TextFormField(
                  controller: _sentenceController,
                  decoration: InputDecoration(
                    hintText: '内容を入力してください',
                    icon: Icon(Icons.text_snippet),
                  ),
                  textInputAction: TextInputAction.next,
                  validator: (value) {
                    if (value.isEmpty) {
                      return '未入力です';
                    }
                    return null;
                  },
                ),
                DateTimePicker(
                  controller: _limitDateController,
                  dateMask: 'yyyy年MM月dd日',
                  type: DateTimePickerType.date,
                  firstDate: DateTime(2019),
                  lastDate: DateTime(2050),
                  decoration: InputDecoration(
                    hintText: '期限を選択してください',
                    icon: Icon(Icons.calendar_today),
                  ),
                  validator: (dateTime) {
                    if (dateTime.isEmpty) {
                      return '日付が未入力です。';
                    }
                    return null;
                  },
                ),
              ],
            ),
          ),
          actions: [
            ElevatedButton(
              onPressed: () {
                Navigator.of(context).pop();
              },
              child: Text('キャンセル'),
            ),
            ElevatedButton(
              onPressed: () async {
                final formatter = DateFormat('yyyy-MM-dd');
                var todo = TodoItemData(
                  id: null,
                  title: _titleController.text,
                  sentence: _sentenceController.text,
                  createdAt: formatter.format(today),
                  limitDate: _limitDateController.text,
                );
                if (_formKey.currentState.validate()) {
                  await context.read(toDoStateNotifierProvider).writeData(todo);
                  Navigator.of(context).pop();
                }
              },
              child: Text('保存'),
            ),
          ],
        );
      },
    );
  }
}
```

# 完成形
簡易的ですが完成形は以下のような感じです。
ひとまず動けば良し、で作ったのでコードは改善すべき部分が多いと思います。
少しでも誰かの参考になれば幸いです。


![Videotogif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1263604/f0183375-7160-f31d-1ca2-e0daa909b25f.gif)