APP

【Flutter】Riverpod + freezed + DriftでTodoアプリを作る【解説付き】

こんにちは、アプリ開発者のテルです!

FlutterでのTodoアプリの作り方がわからない」とお悩みではないでしょうか?

テル

本記事ではそんな悩みを解決していきます!

本記事を読むメリット
  1. FlutterでのTodoアプリの作り方がわかるようになる
  2. Riverpod + freezed + Driftの使い方がわかるようになる
  3. コードを公開しているので、自分の環境で確かめることができる

Riverpod + freezed + DriftでTodoアプリを作る

事前準備

パッケージをインストール

今回使用するパッケージは以下の通りです。

dependencies:
  date_time_picker: ^2.1.0
  drift: ^1.0.1
  drift_dev: ^1.5.2
  flutter:
    sdk: flutter
  flutter_datetime_picker: ^1.5.1
  flutter_form_builder: ^7.1.1
  flutter_riverpod: ^1.0.3
  flutter_slidable: ^1.2.0
  freezed: ^1.0.0
  path: ^1.8.0
  path_provider: ^2.0.7
  sqlite3_flutter_libs: ^0.5.1
dev_dependencies:
  build_runner: ^2.1.8

今回ローカルDBの構築には、Driftを使用します。

「データベース = SQLite」を操作するにはSQL文を書く必要がありますが、Driftを使用することで、SQL文をDartで書くことが出来ます。

SQLiteを使用することで、ローカルにデータを保存することが出来ます!

プロジェクト構成

リポジトリ構成

完成イメージ

本アプリは1画面のみの構成になります。FloatingActionButtonを押すとダイアログが表示され、タスクを作成することが出来ます。

作成したタスクは編集、削除可能です。

それでは、Riverpod + freezed + Driftを用いて上記のアプリを作っていきましょう!

ソースコード

main.dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:todo_list_app/view/home_page.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Todo List',
      theme: ThemeData(
        scaffoldBackgroundColor: const Color(0xFFf0f8ff),
        colorScheme: ColorScheme.fromSwatch().copyWith(
          primary: const Color(0xFF6495ed),
          secondary: const Color(0xFF6495ed),
        ),
      ),
      home: HomePage(),
    );
  }
}

db / db.dart

まず初めに、Driftを使ってデータベースを構築していきましょう!

データベースの構築にはDriftを使用しています。Driftを使用することで、SQLiteを操作するためのSQL文をこのようにDartで書くことが出来ます。

import 'package:drift/drift.dart';
import 'package:drift/native.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'dart:io';

part 'db.g.dart';

// テーブルの作成
class TodoItem extends Table {
  // ①主キー(autoIncrementで自動的にIDを設置する)
  IntColumn get id => integer().autoIncrement()();
  // ②タイトル(デフォルト値と長さを指定する)
  TextColumn get title =>
      text().withDefault(const Constant('')).withLength(min: 1)();
  // ③説明文(デフォルト値を指定する)
  TextColumn get description => text().withDefault(const Constant(''))();
  // ④日付(nullを許容する)
  DateTimeColumn get date => dateTime().nullable()();
}

// データベースの場所を指定
LazyDatabase _openConnection() {
  return LazyDatabase(() async {
    // db.sqliteファイルをアプリのドキュメントフォルダに置く
    final dbFolder = await getApplicationDocumentsDirectory();
    final file = File(p.join(dbFolder.path, 'db.splite'));
    return NativeDatabase(file);
  });
}

// データベースの実行
@DriftDatabase(tables: [TodoItem])
class MyDatabase extends _$MyDatabase {
  MyDatabase() : super(_openConnection());
  // テーブルと列を変更するときに使用する
  @override
  int get schemaVersion => 1;

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

  // データの追加
  Future writeTodo(TodoItemCompanion 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();
}

上記の内容が書き終わったら、こちらのコマンドを回してください。

flutter pub run build_runner watch --delete-conflicting-outputs

これにより、ファイルが自動生成されます。–delete-conflicting-outputsを加えることで、クラスに変更が加えられる度に再生成されます。

modelsフォルダが上記のようになっていれば問題ありません。これで、データベースの構築、データベースを操作するための準備が整いました。

freezed / todo.dart

次に「DBの状態を保持するクラス」と「入力中のtodoの状態を保持するクラス」を作成していきます。今回はタイトル/説明文/日付の3つを取得したいと思います。

クラスの作成にはfreezedを使用しています。freezedを使用することで、不変クラスを作成することが出来ます。

import 'package:todo_list_app/model/db/db.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

part 'todo.freezed.dart';

@freezed
class TodoStateData with _$TodoStateData {
  // DBの状態を保持するクラス
  factory TodoStateData({
    @Default(false) bool isLoading,
    @Default(false) bool isReadyData,
    @Default([]) List<TodoItemData> todoItems,
  }) = _TodoStateData;
}

@freezed
class TempTodoItemData with _$TempTodoItemData {
  // 入力中のtodoの状態を保持するクラス
  factory TempTodoItemData({
    @Default('') String title,
    @Default('') String description,
    @Default(null) DateTime? date,
    @Default(true) bool isNotify,
  }) = _TempTodoItemData;
}

freezedでクラスを作成したら、下記のコマンドを回してください。

flutter pub run build_runner watch --delete-conflicting-outputs

これにより、ファイルが自動生成されます。–delete-conflicting-outputsを加えることで、クラスに変更が加えられる度に再生成されます。

modelフォルダが上記のようになっていれば問題ありません。これで、DBの状態を保持するクラスと入力中のtodoの状態を保持するクラスを作成することが出来ました。

view_model / todo_notifier.dart

次に、データベースとView間のやりとりを状態管理するためのファイルを作成します。 操作内容をStateNotifierにまとめ、StateNotifierProviderで状態を管理します。

import 'package:drift/drift.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:todo_list_app/model/db/db.dart';
import 'package:todo_list_app/model/freezed/todo.dart';

// DBの操作を行うクラス (dbの操作にstateを絡める)
class TodoDatabaseNotifier extends StateNotifier<TodoStateData> {
  TodoDatabaseNotifier() : super(TodoStateData());

  final _db = MyDatabase(); //DBへの操作を行う

  // 書き込み処理
  writeData(TempTodoItemData data) async {
    if (data.title.isEmpty) {
      return;
    }
    TodoItemCompanion entry = TodoItemCompanion(
      title: Value(data.title),
      description: Value(data.description),
      date: Value(data.date),
    );
    state = state.copyWith(isLoading: true);
    await _db.writeTodo(entry); //テーブルに入力されたデータを送る
    readData();
  }

  // 更新処理
  updateData(TodoItemData data) async {
    if (data.title.isEmpty) {
      return;
    }
    state = state.copyWith(isLoading: true);
    await _db.updateTodo(data);
    readData();
  }

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

  // 読み込み処理
  readData() async {
    state = state.copyWith(isLoading: true);
    final todoItems = await _db.readAllTodoData();
    state = state.copyWith(
      isLoading: false,
      isReadyData: true,
      todoItems: todoItems,
    );
  }
}

// 無名関数の中に処理を書くことで初期化を可能にしている。これにより、常に最新の状態を管理できる。
final todoDatabaseProvider = StateNotifierProvider((_) {
  TodoDatabaseNotifier notify = TodoDatabaseNotifier();
  notify.readData();
  return notify; // 初期化
});

// 入力された新規タスクの状態を管理する
final titleProvider = StateProvider((ref) => '');
final descriptionProvider = StateProvider((ref) => '');
final dateProvider = StateProvider<DateTime?>((ref) => null);

これで、データベースとView間のやりとりをまとめることができました。

view / todo_page.dart

最後に、TodoアプリのUI(見た目)を作っていきます。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:intl/intl.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_datetime_picker/flutter_datetime_picker.dart';
import 'package:todo_list_app/model/db/db.dart';
import 'package:todo_list_app/model/freezed/todo.dart';
import 'package:todo_list_app/view_model/todo_notifier.dart';

class TodoPage extends ConsumerWidget {
  // 入力中のtodoのインスタンスを作成
  TempTodoItemData temp = TempTodoItemData();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 状態が変化するたびに再ビルドする
    final todoState = ref.watch(todoDatabaseProvider);

    // メソッドや値を取得する
    final todoNotifier = ref.watch(todoDatabaseProvider.notifier);

    // 追加画面を閉じたら再ビルドするために使用する
    List<TodoItemData> todoItems = todoNotifier.state.todoItems;

    // todoの一覧を格納するリスト
    List<Widget> tiles = _buildTodoList(todoItems, todoNotifier);

    return Scaffold(
      appBar: AppBar(title: const Text('Todo List')),
      body: ListView(children: tiles),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.add),
        onPressed: () async {
          await showDialog(
            context: context,
            builder: (context2) {
              return Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  _createTask(),
                ],
              );
            },
          );
          temp = TempTodoItemData();
        },
      ),
    );
  }

  // todo一覧
  List<Widget> _buildTodoList(
    List<TodoItemData> items,
    TodoDatabaseNotifier db,
  ) {
    List<Widget> list = [];
    for (TodoItemData item in items) {
      Widget tile = _tile(item, db);
      list.add(tile);
    }
    return list;
  }

  // todo
  Widget _tile(TodoItemData item, TodoDatabaseNotifier db) {
    return Consumer(
      builder: ((context, ref, child) {
        return Slidable(
          child: Card(
            child: ListTile(
              title: Text(item.title),
              subtitle: Text(item.description),
              trailing: Text(
                item.date == null
                    ? ""
                    : DateFormat('M/dd H:mm').format(item.date!),
              ),
            ),
          ),
          endActionPane: ActionPane(
            motion: const DrawerMotion(),
            children: [
              SlidableAction(
                flex: 1,
                icon: Icons.create,
                backgroundColor: Colors.green,
                label: '編集',
                onPressed: (_) async {
                  await showDialog(
                    context: context,
                    builder: (context2) {
                      return Column(
                        mainAxisAlignment: MainAxisAlignment.center,
                        children: [
                          _editTask(item),
                        ],
                      );
                    },
                  );
                },
              ),
              SlidableAction(
                flex: 1,
                icon: Icons.delete,
                backgroundColor: Colors.red,
                label: '削除',
                onPressed: (_) {
                  db.deleteData(item);
                },
              ),
            ],
          ),
        );
      }),
    );
  }

  // タスクの作成
  Widget _createTask() {
    return Consumer(
      builder: ((context, ref, child) {
        // 状態が変化するたびに再ビルドする
        final todoState = ref.watch(todoDatabaseProvider);

        // メソッドや値を取得する
        final todoNotifier = ref.watch(todoDatabaseProvider.notifier);

        // 日付の表示を管理する
        final dateState = ref.watch(dateProvider);
        final dateNotifier = ref.watch(dateProvider.notifier);

        return AlertDialog(
          title: const Text("New Task"),
          content: SingleChildScrollView(
            child: ListBody(
              children: [
                Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: [
                    TextField(
                      decoration: const InputDecoration(
                        labelText: 'タイトル',
                      ),
                      onChanged: (value) {
                        temp = temp.copyWith(title: value);
                      },
                    ),
                    TextField(
                      decoration: const InputDecoration(
                        labelText: '説明',
                      ),
                      onChanged: (value) {
                        temp = temp.copyWith(description: value);
                      },
                    ),
                    const SizedBox(height: 20.0),
                    Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: [
                        ElevatedButton(
                          onPressed: () {
                            DatePicker.showDateTimePicker(
                              context,
                              showTitleActions: true,
                              minTime: DateTime.now(),
                              onConfirm: (date) {
                                dateNotifier.state = date;
                                temp = temp.copyWith(date: date);
                              },
                              currentTime: DateTime.now(),
                              locale: LocaleType.jp,
                            );
                          },
                          child: Row(
                            mainAxisAlignment: MainAxisAlignment.spaceBetween,
                            children: [
                              const Icon(Icons.calendar_today),
                              const SizedBox(width: 5.0),
                              Text(
                                temp.date == null
                                    ? "日付指定"
                                    : DateFormat('M/dd H:mm')
                                        .format(temp.date!),
                              ),
                            ],
                          ),
                        ),
                        ElevatedButton(
                          onPressed: () {
                            // データの追加
                            todoNotifier.writeData(temp);
                            Navigator.pop(context);
                          },
                          child: const Text('OK'),
                        ),
                      ],
                    ),
                  ],
                ),
              ],
            ),
          ),
        );
      }),
    );
  }

  // タスクの編集
  Widget _editTask(TodoItemData item) {
    return Consumer(
      builder: ((context, ref, child) {
        // 状態が変化するたびに再ビルドする
        final todoState = ref.watch(todoDatabaseProvider);

        // メソッドや値を取得する
        final todoNotifier = ref.watch(todoDatabaseProvider.notifier);

        // 日付の表示を管理する
        final dateState = ref.watch(dateProvider);
        final dateNotifier = ref.watch(dateProvider.notifier);

        // コントローラー
        final _titleController = TextEditingController(text: item.title);
        final _descriptionController =
            TextEditingController(text: item.description);

        var edited = item;

        return AlertDialog(
          title: const Text("Edit Task"),
          content: SingleChildScrollView(
            child: ListBody(
              children: [
                Column(
                  crossAxisAlignment: CrossAxisAlignment.stretch,
                  children: [
                    TextField(
                      controller: _titleController,
                      decoration: const InputDecoration(
                        labelText: 'タイトル',
                      ),
                      onChanged: (value) {
                        edited = edited.copyWith(title: value);
                      },
                    ),
                    TextField(
                      controller: _descriptionController,
                      decoration: const InputDecoration(
                        labelText: '説明',
                      ),
                      onChanged: (value) {
                        edited = edited.copyWith(description: value);
                      },
                    ),
                    const SizedBox(height: 20.0),
                    Row(
                      mainAxisAlignment: MainAxisAlignment.spaceBetween,
                      children: [
                        ElevatedButton(
                          onPressed: () {
                            DatePicker.showDateTimePicker(
                              context,
                              showTitleActions: true,
                              minTime: DateTime.now(),
                              onConfirm: (date) {
                                edited = edited.copyWith(date: date);
                              },
                              currentTime: DateTime.now(),
                              locale: LocaleType.jp,
                            );
                          },
                          child: Row(
                            mainAxisAlignment: MainAxisAlignment.spaceBetween,
                            children: [
                              const Icon(Icons.calendar_today),
                              const SizedBox(width: 5.0),
                              Text(
                                edited.date == null
                                    ? ""
                                    : DateFormat('M/dd H:mm')
                                        .format(edited.date!),
                              ),
                            ],
                          ),
                        ),
                        ElevatedButton(
                          onPressed: () {
                            // データの更新
                            todoNotifier.updateData(edited);
                            Navigator.pop(context);
                          },
                          child: const Text('OK'),
                        ),
                      ],
                    ),
                  ],
                ),
              ],
            ),
          ),
        );
      }),
    );
  }
}

大変お疲れ様でした!以上でアプリは完成です!

今回ご紹介したアプリ全体のソースコードはこちらです。

よろしければ、ご参考にどうぞ。

GitHub:https://github.com/terupro/todo_list_app

まとめ

今回は「FlutterでのTodoアプリの作り方」をご紹介しました。

Driftを使用することでデータベース周りもDartで書くことができ、全てDartで完結できるので非常にスッキリします。

ぜひ今回のサンプルを参考に、自分で色々と試してみてください。

最後までご覧いただきありがとうございました。ではまた!

Zennにて、Flutter本を出版しました!