Flutter + Riverpod + freezedでTodoアプリ作成🍖

## はじめに
Flutter + Riverpod + freezedでTodoアプリを作成したので、作成手順を共有します。

## イメージ
下記のREADMEに作成後の動画があります。
https://github.com/life-with-meat/flutter-todo-riverpod/blob/main/README.md

## 目次
1. ソースコード
1. Flutter create
1. パッケージ追加
1. Model作成
1. StateNotifier作成
1. Provider作成
1. Ui作成


## サンプルのソースコード
今回作成するTodoアプリのサンプルコードになります。
この記事で書かれているディレクトリ構成とは違っているため、ファイル単体で参考にしてください。

https://github.com/life-with-meat/flutter-todo-riverpod

## Flutter create
Flutterプロジェクトを作成します。

```
flutter create --platforms=android,ios --empty my_app
cd my_app
```

## パッケージ追加
必要なパッケージを追加します。
```
flutter pub add flutter_riverpod freezed_annotation
flutter pub add --dev build_runner freezed
```

## Model作成（Freezed）
TodoのModelをFreezedを使って作成します。


<details>
<summary>コードを見る</summary

#### lib/model/todo_state.dart

```
import 'package:freezed_annotation/freezed_annotation.dart';

part 'todo_state.freezed.dart';

@freezed
abstract class TodoState with _$TodoState {
  factory TodoState({
    required String id,
    required String title,
    required bool isDone,
  }) = _TodoState;
}

```

</details>


モデルを作成したあとには、次のコマンドで .freezed.dart ファイルを生成します：

```
flutter pub run build_runner build --delete-conflicting-outputs
```



## StateNotifier作成

TodoPage の状態管理を行う StateNotifier を作成します。

<details>
<summary>コードを見る</summary>

#### lib/controller/todo_page_controller.dart

```
class TodoPageController extends StateNotifier<List<TodoState>> {
  TodoPageController() : super([]);

  void add(String title) {
    final newTodo = TodoState(
      id: DateTime.now().toIso8601String(),
      title: title,
      isDone: false,
    );
    state = [...state, newTodo];
  }

  void toggle(String id) {
    state = [
      for (final todo in state)
        if (todo.id == id) todo.copyWith(isDone: !todo.isDone) else todo,
    ];
  }

  void delete(String id) {
    state = state.where((todo) => todo.id != id).toList();
  }

  void edit(String id, String newTitle) {
    state = [
      for (final todo in state)
        if (todo.id == id) todo.copyWith(title: newTitle) else todo,
    ];
  }
}

```

</details>

## Provider作成
TodoPageControllerをアプリ全体で使えるように、StateNotifierProviderを定義します。

<details>
<summary>コードを見る</summary>

#### lib/controller/todo_page_controller.dart

```

final todoPageControllerProvider =
    StateNotifierProvider<TodoPageController, List<TodoState>>((ref) {
      return TodoPageController();
    });

```

</details>


エラーメッセージの表示状態を管理するための `StateProvider` を作成します。  
入力フォームでバリデーションエラーが発生したときに使用します。

#### lib/controller/todo_page_controller.dart;

```
final editErrorProvider = StateProvider<String?>((ref) => null);

```

## ProviderScopeを設定します。

Providerを使用出来るようにするため、ProviderScopeを設定します。


<details>
<summary>コードを見る</summary>

#### lib/main.dart

```
void main() {
  runApp(const ProviderScope(child: MyApp()));
}
```

</details>


## Ui作成

Pageを作成します。

<details>
<summary>コードを見る</summary>

#### lib/page/todo_page.dart

```

class TodoPage extends ConsumerWidget {
  const TodoPage({super.key});
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final todoPageState = ref.watch(todoPageControllerProvider);
    final todoPageController = ref.read(todoPageControllerProvider.notifier);

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('Todo リスト'),
      ),

      body: Center(
        child: ListView.builder(
          itemCount: todoPageState.length,
          itemBuilder: (context, index) {
            final todo = todoPageState[index];
            return CheckboxListTile(
              title: Text(
                todo.title,
                style: TextStyle(
                  decoration: todo.isDone ? TextDecoration.lineThrough : null,
                ),
              ),
              value: todo.isDone,
              activeColor: todo.isDone ? Colors.grey : null,
              tileColor: todo.isDone ? Colors.grey.shade300 : null,
              onChanged: (value) {
                todoPageController.toggle(todo.id);
              },
              secondary: PopupMenuButton<String>(
                icon: const Icon(Icons.more_vert),
                onSelected: (String value) {
                  if (value == 'edit') {
                    showDialog(
                      context: context,
                      builder: (BuildContext context) {
                        return TodoDialog(
                          initialTitle: todo.title,
                          dialogTitle: 'タスクを編集',
                          errorProvider: editErrorProvider,
                          onSave: (newTitle) {
                            todoPageController.edit(todo.id, newTitle);
                          },
                        );
                      },
                    );
                  } else if (value == 'delete') {
                    todoPageController.delete(todo.id);
                  }
                },
                itemBuilder:
                    (BuildContext context) => <PopupMenuEntry<String>>[
                      const PopupMenuItem<String>(
                        value: 'edit',
                        child: Text('編集'),
                      ),
                      const PopupMenuItem<String>(
                        value: 'delete',
                        child: Text('削除'),
                      ),
                    ],
              ),
            );
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          showDialog(
            context: context,
            builder: (BuildContext context) {
              return TodoDialog(
                initialTitle: '',
                dialogTitle: '新しいタスク',
                errorProvider: editErrorProvider,
                onSave: (newTitle) {
                  todoPageController.add(newTitle);
                },
              );
            },
          );
        },
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

```

</details>


Widgetを作成します。

Todo編集ダイアログのWidgetです。

<details>
<summary>コードを見る</summary>

#### lib/widget/todo_dialog.dart

```

class TodoPage extends ConsumerWidget {
  const TodoPage({super.key});
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final todoPageState = ref.watch(todoPageControllerProvider);
    final todoPageController = ref.read(todoPageControllerProvider.notifier);

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('Todo リスト'),
      ),

      body: Center(
        child: ListView.builder(
          itemCount: todoPageState.length,
          itemBuilder: (context, index) {
            final todo = todoPageState[index];
            return CheckboxListTile(
              title: Text(
                todo.title,
                style: TextStyle(
                  decoration: todo.isDone ? TextDecoration.lineThrough : null,
                ),
              ),
              value: todo.isDone,
              activeColor: todo.isDone ? Colors.grey : null,
              tileColor: todo.isDone ? Colors.grey.shade300 : null,
              onChanged: (value) {
                todoPageController.toggle(todo.id);
              },
              secondary: PopupMenuButton<String>(
                icon: const Icon(Icons.more_vert),
                onSelected: (String value) {
                  if (value == 'edit') {
                    showDialog(
                      context: context,
                      builder: (BuildContext context) {
                        return TodoDialog(
                          initialTitle: todo.title,
                          dialogTitle: 'タスクを編集',
                          errorProvider: editErrorProvider,
                          onSave: (newTitle) {
                            todoPageController.edit(todo.id, newTitle);
                          },
                        );
                      },
                    );
                  } else if (value == 'delete') {
                    todoPageController.delete(todo.id);
                  }
                },
                itemBuilder:
                    (BuildContext context) => <PopupMenuEntry<String>>[
                      const PopupMenuItem<String>(
                        value: 'edit',
                        child: Text('編集'),
                      ),
                      const PopupMenuItem<String>(
                        value: 'delete',
                        child: Text('削除'),
                      ),
                    ],
              ),
            );
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          showDialog(
            context: context,
            builder: (BuildContext context) {
              return TodoDialog(
                initialTitle: '',
                dialogTitle: '新しいタスク',
                errorProvider: editErrorProvider,
                onSave: (newTitle) {
                  todoPageController.add(newTitle);
                },
              );
            },
          );
        },
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}
```

</details>

入力欄のWidgetです。

<details>
<summary>コードを見る</summary>

#### lib/widget/app_text_field.dart

```
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

class AppTextField extends ConsumerStatefulWidget {
  final String initialValue;
  final String label;
  final StateProvider<String?> errorProvider;
  final ValueChanged<String> onChanged;

  const AppTextField({
    super.key,
    required this.initialValue,
    required this.label,
    required this.errorProvider,
    required this.onChanged,
  });

  @override
  ConsumerState<AppTextField> createState() => _AppTextFieldState();
}

class _AppTextFieldState extends ConsumerState<AppTextField> {
  late final TextEditingController _controller;

  @override
  void initState() {
    super.initState();
    _controller = TextEditingController(text: widget.initialValue);
  }

  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return TextField(
      controller: _controller,
      decoration: InputDecoration(
        labelText: widget.label,
        errorText: ref.watch(widget.errorProvider),
      ),
      onChanged: (value) {
        widget.onChanged(value);
        ref.read(widget.errorProvider.notifier).state = null;
      },
    );
  }
}

```


</details>