🍋

【Flutter】sqfliteで作るコンパクトなMVVM+RepositoryパターンのTodoアプリを作ってみた

2022/12/19に公開

はじめに

この記事はTechCommit AdventCalendar2022の19日目の記事です。

5月ごろから業務でFlutterを使うことになり、勉強も兼ねてTodoアプリを作ったのでそちらを紹介していこうと思います。
作り始めたのは結構前なのですが、何もしてない時期があったのでまだ完成しておらず中途半端な形ですが、ご容赦いただければ幸いです。

アプリの概要

コードはこちらです
https://github.com/pipipi09/todo_app

後々Habit Tracker兼Todoアプリにしていきたいと思い、日付ごとにTodoを登録できるTodoアプリにしました。

その日のTodoが全て完了すると、カレンダーにお花が咲きます。

使用したバージョン、パッケージなど

  • Flutter 3.0.1
  • hooks_riverpod 2.0.0-dev.9
  • freezed: 2.0.3+1
  • sqflite 2.0.2+1
  • table_calendar: 3.0.8

色々とバージョンが古くなっていたことに気づきました。
細かいものは他にも色々ありますが、主なものはこのような感じです。

アーキテクチャ

MVVMとタイトルに記載しましたが、モバイルアプリの開発は初めてでMVVMについてもあまりよくわかっていませんでした。
MVVMについては丁寧な解説記事がたくさんあるのでここでは簡単に記述しますと
M(Model) V(View) VM(ViewModel)で役割分担をしよう、ということらしいです。
Modelはデータ、ViewはUI、ViewModelはModelを加工してViewに渡す、といったイメージです。
さらに、ModelとViewModelの間に、データの取得だけを担う「Repository」を挟むことでViewModelがデータ加工の役割に集中できるようになります。
これをMVVM+Repositoryパターンと呼ぶそうです。
今回はMVVM+Repositoryの構造を意識して開発していくことにしました。

参考にさせていただいた主な記事はこちらです。
https://zenn.dev/itakahiro/articles/29ba92485a0df6
https://zenn.dev/alesion/articles/ab2df82a3809b7
https://wasabeef.medium.com/flutter-を-mvvm-で実装する-861c5dbcc565

データの保存について

今回はFlutterの勉強がメインの目的のためBE開発は行わず、モバイル端末に直接データを保存する形にしました。

Flutterで端末にデータを保存する方法はshared_preferencesなどがありますが、追々機能も増やしていきたかったためDBを利用できるsqfliteを使用することにしました。
sqfliteはFlutterでSQLiteを使用できるようにするパッケージです。

sqfliteはこちらの記事を参考に、path_providerを使用してセットアップしました。
https://zenn.dev/beeeyan/articles/b9f1b42de9cb67

実際に書いたコードなど

ざっくりですが、こんなふうに書いてみました!というのを紹介します。

Model

Modelはlib/models配下に置いています。

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

part 'todo_model.freezed.dart';
part 'todo_model.g.dart';


class TodoModel with _$TodoModel {
  const factory TodoModel({
    /// id
    String? id,

    /// Todoの内容
    ('') String? text,

    /// Todoを行う日付 UnixTime
    (0) int? date,

    /// 作成日 UnixTime
    (name: 'created_at') (0) int? createdAt,

    /// 更新日 UnixTime
    (name: 'updated_at') (0) int? updatedAt,
  }) = _TodoModel;

  const TodoModel._();

  /// JsonからTodoModelを生成する
  
  factory TodoModel.fromJson(Map<String, dynamic> json) =>
      _$TodoModelFromJson(json);

  static String get tableName => 'todos';

  DateTime get dateTime => DateTime.fromMillisecondsSinceEpoch(date ?? 0);

  String get formatDate => DateFormat('yyyy-MM-dd').format(dateTime);
}

modelはDBから取得したデータなのでimmutableにしました。
immutableなクラスを作成する際はfreezedパッケージが便利なため、freezedアノテーションをつけて作成します。

また、データのやり取りでfromJsonとtoJsonを使用したいのでpart 'xxx.g.dart'; を追加してfromJsonをoverrideしています。
https://pub.dev/documentation/freezed/latest/#:~:text=extended nor implemented.-,FromJson/ToJson,-While Freezed will

fromJsonは名前の通り、json形式のものからクラスのインスタンスを作成できるため、DBやAPIから取得したデータでインスタンスを作成するのに便利です。
toJsonはその逆で、modelクラスからjsonの作成が簡単にでき、insertやupdateしたりするときに便利です。

DBのテーブル名やカラム名はスネークケースで命名されると思いますが、Dartのメンバ変数名はlowerCamelCaseが推奨されているので、@JsonKeyを使ってjson形式との変換の際に使用するkeyを指定しておきました。
https://dart.dev/guides/language/effective-dart/style#do-name-other-identifiers-using-lowercamelcase

その他、SQLiteにはDateTime型などが保存できないためUnixTimeとDateTimeの変換を楽にするためにgetterなどを置いておきました。
テーブル名もstaticで定義しておき、Genericsを使用した際にモデルクラスからテーブル名を指定できるようにしておきました。
APIを使用する際はエンドポイントのパスなどを定義する便利になると思います。

View

Viewはlib/viewsに置くようにしました。

WidgetはAtomic Designを意識して1コンポーネント1ファイルにするように実装しています。(といいつつちゃんと分けられていない部分もあります・・)

todo_list_page.dart

import '../../view_model/date_view_model.dart';
import '../organisms/app_bar_organism.dart';
import '../todo_list/organisms/input_todo_organism/input_todo_organism.dart';
import '../todo_list/organisms/todo_list_organism.dart';
import 'calendar_page.dart';

class TodoListPage extends ConsumerWidget {
  const TodoListPage({super.key});

  void _gotoCalendarPage(BuildContext context) {
    Navigator.push(
      context,
      MaterialPageRoute(
        builder: (context) => const CalendarPage(),
      ),
    );
  }

  void _showModalForCreateTodo(BuildContext context, {required DateTime date}) {
    showModalBottomSheet(
      backgroundColor: Colors.transparent,
      isScrollControlled: true,
      context: context,
      builder: (BuildContext context) {
        return InputTodoOrganism(
          editTodo: TodoModel(date: date.millisecondsSinceEpoch),
        );
      },
    );
  }

  
  Widget build(BuildContext context, WidgetRef ref) {
    final displayDate = ref.watch(displayDateViewModelProvider);
    return Scaffold(
      appBar: AppBarOrganism(
        date: displayDate,
        onPressed: () {
          _gotoCalendarPage(context);
        },
      ),
      body: const SafeArea(
        child: TodoListOrganism(),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          _showModalForCreateTodo(context, date: displayDate);
        },
        child: const Icon(
          Icons.add,
          color: Colors.white,
        ),
      ),
    );
  }
}

見通しを良くするためにbuild内のメソッドは外に書き出しました。
まだうまく分けられていませんが、Atomic DesignでTemplates以下になるなと思った部分は別のファイルにしてimportしています。

状態管理はriverpodを使うので基本的にproviderを使っていますが、クラスなど作らずに簡単に管理したいなーというところはfultter_hooksを使用しています。
ですが、メソッドを分けるときに少し不便かなと思う部分も出てきたので、追々providerで統一したいと思ってます。(探り探りなため、こうするといいよ!というのが合ったらぜひ教えてください)

Repository

Repositoryはlib/repositoryに置くようにしました。

Repository抽象クラスを作って各modelクラス用にそれぞれ実装していますが、modelごとに特別な処理がなければ、Genericsを使ってrepositoryクラスはひとつにできるかと思いました。

また、データの取得結果によって分岐できるように、freezedを使ってResultクラスを作成しています。
この辺りは https://zenn.dev/alesion/articles/ab2df82a3809b7 の記事を参考にしました。

Result

import 'package:freezed_annotation/freezed_annotation.dart';

part 'result.freezed.dart';


abstract class Result<T> with _$Result<T> {
  const Result._();

  const factory Result.success({required T data}) = Success<T>;

  const factory Result.failure({required Exception error}) = Failure<T>;

  static Result<T> guard<T>(T Function() body) {
    try {
      return Result.success(data: body());
    } on Exception catch (e) {
      return Result.failure(error: e);
    }
  }

  static Future<Result<T>> guardFuture<T>(Future<T> Function() future) async {
    try {
      return Result.success(data: await future());
    } on Exception catch (e) {
      return Result.failure(error: e);
    }
  }

  bool get isSuccess => when(success: (data) => true, failure: (e) => false);

  bool get isFailure => !isSuccess;

  T get dataOrThrow {
    return when(
      success: (data) => data,
      failure: (e) => throw e,
    );
  }
}

extension ResultObjectExt<T> on T {
  Result<T> get asSuccess => Result.success(data: this);

  Result<T> asFailure(Exception e) => Result.failure(error: e);
}

Repository

import '../model/result/result.dart';

abstract class Repository<T> {
  Future<Result<List<T>>> fetch({
    String? where,
    List? whereArgs,
  });

  Future<Result<int>> save(T model);

  Future<Result<int>> update(T model);

  Future<Result<int>> delete(T model);
}

import 'package:hooks_riverpod/hooks_riverpod.dart';

import '../db/db_controller.dart';
import '../model/todos/todo_model.dart';
import '../model/result/result.dart';
import 'repository.dart';

final todoRepositoryProvider =
    Provider<Repository<TodoModel>>((ref) => TodoRepositoryImpl());

class TodoRepositoryImpl implements Repository<TodoModel> {
  TodoRepositoryImpl();

  /// [where]は id = ? のような形式にする
  
  Future<Result<List<TodoModel>>> fetch({
    String? where,
    List? whereArgs,
  }) async {
    return Result.guardFuture(() async {
      final result = await DbController.db.get(
        tableName: TodoModel.tableName,
        where: where,
        whereArgs: whereArgs,
      );
      return result.map((e) => TodoModel.fromJson(e)).toList();
    });
  }

  
  Future<Result<int>> save(TodoModel todo) async {
    return Result.guardFuture(
      () async => await DbController.db
          .create(tableName: TodoModel.tableName, json: todo.toJson()),
    );
  }

  
  Future<Result<int>> update(TodoModel todo) async {
    return Result.guardFuture(
      () async => await DbController.db.update(
        tableName: TodoModel.tableName,
        json: todo.toJson(),
        primaryKey: todo.id!,
      ),
    );
  }

  
  Future<Result<int>> delete(TodoModel todo) async {
    return Result.guardFuture(
      () async => await DbController.db.delete(
        tableName: TodoModel.tableName,
        primaryKey: todo.id!,
      ),
    );
  }
}


ViewModel

ViewModelはlib/view_modelsにファイルを置くようにしました。

View Modelでは主に画面に出すデータを取り扱うので、状態管理できる状態にしておきました。
StateNotifierProviderで、データの参照元に変更を通知するようにしておきます。
ViewModelで状態管理せず、別にStateを用意しておいた方がよいのかわからなかったので、この辺りについても何かあればコメントいただけると嬉しいです。

import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:uuid/uuid.dart';

import '../model/result/result.dart';
import '../model/todos/todo_model.dart';
import '../repository/repository.dart';
import '../repository/todo_repository_impl.dart';
import 'date_view_model.dart';

/// Todoリストを取得し配布する
final todoListViewModelProvider =
    StateNotifierProvider<TodoListViewModel, AsyncValue<List<TodoModel>>>(
  (ref) => TodoListViewModel(
    ref.watch(todoRepositoryProvider),
    ref.watch(displayDateViewModelProvider),
  ),
);

/// Todoリスト
class TodoListViewModel extends StateNotifier<AsyncValue<List<TodoModel>>> {
  /// constructor
  /// インスタンス生成時にloadを実行してデータを取得する
  TodoListViewModel(this.todoRepository, this.displayDate)
      : super(const AsyncData([])) {
    loadByDate(displayDate);
  }

  /// todoRepository
  final Repository<TodoModel> todoRepository;

  final DateTime displayDate;

  /// 1日分のデータを取得する
  Future<Result<List<TodoModel>>> loadByDate(DateTime date) async {
    final startOfDate = DateTime(date.year, date.month, date.day);
    final endOfDate = DateTime(date.year, date.month, date.day, 23, 59);
    const where = "date between ? and ?";
    final whereArgs = [
      startOfDate.millisecondsSinceEpoch,
      endOfDate.millisecondsSinceEpoch
    ];
    final result =
        await todoRepository.fetch(where: where, whereArgs: whereArgs);
    return result.when(
      success: (data) {
        state = AsyncValue.data(data);
        return Result.success(data: data);
      },
      failure: (error) {
        state = AsyncValue.error(error);
        return Result.failure(error: error);
      },
    );
  }

  /// [todo]を保存する
  /// primary keyがなければsave, あればupdateをする
  Future<Result<String>> save(TodoModel todo) async {
    const uuid = Uuid();
    if (todo.id == null) {
      final saveData = todo.copyWith(id: uuid.v4());
      final result = await todoRepository.save(saveData);
      return result.when(
        success: (data) {
          loadByDate(saveData.dateTime);
          return Result.success(data: saveData.id!);
        },
        failure: (error) {
          state = AsyncValue.error(error);
          return Result.failure(error: error);
        },
      );
    } else {
      final result = await todoRepository.update(todo);
      return result.when(
        success: (data) {
          loadByDate(todo.dateTime);
          return Result.success(data: todo.id!);
        },
        failure: (error) {
          state = AsyncValue.error(error);
          return Result.failure(error: error);
        },
      );
    }
  }

  /// [todo]で指定したデータを削除する
  Future<Result<int>> delete(TodoModel todo) async {
    final result = await todoRepository.delete(todo);
    return result.when(
      success: (data) {
        loadByDate(todo.dateTime);
        return Result.success(data: data);
      },
      failure: (error) {
        state = AsyncValue.error(error);
        return Result.failure(error: error);
      },
    );
  }
}


sqflite

バージョンが古かったからかもしれませんが、公式ドキュメントのサンプル通りに実装したらあまりうまくいかなかったので、色々調べた上で以下のような形になっています。
有識者の方、もっとこうするといいよ!というのがあれば教えてください。。
バージョンを上げたらサンプル通りに書けるかもしれないので、それは追々試してみようと思います。

また、テーブルを途中で増やしたかったので、_upgradeTabelV1toV2を作っています。

メソッドは、select、insert、update、deleteの分を作りました。

import 'dart:async';
import 'dart:io';

import 'package:path/path.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sqflite/sqflite.dart';

import '../model/completed_todos/completed_todo_model.dart';
import '../model/repeats/repeat_model.dart';
import '../model/todos/todo_model.dart';
import '../utils/logger.dart';

class DbController {
  DbController._();

  static final DbController db = DbController._();

  static Database? _database;

  /// initDBをしてdatabaseを使用する
  Future<Database> get database async => _database ??= await initDB();

  static const dbName = "TodoApp.db";

  static final todosTableName = TodoModel.tableName;
  static final completedTodosTableName = CompletedTodoModel.tableName;
  static final repeatsTableName = RepeatModel.tableName;

  /// initDB
  /// DataBaseのバージョンが違う場合はtableを作成する
  Future<Database> initDB() async {
    Directory documentsDirectory = await getApplicationDocumentsDirectory();
    String path = join(documentsDirectory.path, dbName);
    return await openDatabase(
      path,
      version: 2,
      onCreate: _createTable,
      onUpgrade: _upgradeTabelV1toV2,
    );
  }

  /// tableを作成する
  Future<void> _createTable(Database db, int version) async {
    final batch = db.batch();
    batch.execute('DROP TABLE IF EXISTS $dbName');
    batch.execute(
      "CREATE TABLE $todosTableName("
      "id TEXT PRIMARY KEY,"
      "text TEXT NOT NULL,"
      "date INTEGER NOT NULL,"
      "created_at INTEGER NOT NULL,"
      "updated_at INTEGER NOT NULL,"
      ");",
    );
    batch.execute(
      "CREATE TABLE $completedTodosTableName("
      "id TEXT PRIMARY KEY,"
      "date INTEGER NOT NULL,"
      "todo_id TEXT NOT NULL"
      "created_at INTEGER NOT NULL,"
      "updated_at INTEGER NOT NULL,"
      "FOREIGN KEY (todo_id) REFERENCES todos(id) ON DELETE CASCADE"
      ");",
    );
    batch.execute(
      "CREATE TABLE $repeatsTableName("
      "id TEXT PRIMARY KEY,"
      "todo_id TEXT NOT NULL"
      "frequency_id TEXT NOT NULL"
      "hour INTEGER"
      "minute INTEGER"
      "weekday INTEGER"
      "day INTEGER"
      "month INTEGER"
      "end INTEGER"
      "created_at INTEGER NOT NULL,"
      "updated_at INTEGER NOT NULL,"
      "FOREIGN KEY (todo_id) REFERENCES todos(id) ON DELETE CASCADE"
      ");",
    );
    await batch.commit();
  }

  FutureOr<void> _upgradeTabelV1toV2(
    Database db,
    int oldVersion,
    int newVersion,
  ) async {
    if (oldVersion == 1 && newVersion == 2) {
      final batch = db.batch();
      logger.i('database updgrade to $newVersion from $oldVersion');
      batch.execute("DROP TABLE IF EXISTS completed_todos");
      batch.execute(
        "CREATE TABLE completed_todos("
        "id TEXT PRIMARY KEY,"
        "date INTEGER NOT NULL,"
        "todo_id TEXT NOT NULL,"
        "created_at INTEGER NOT NULL,"
        "updated_at INTEGER NOT NULL,"
        "FOREIGN KEY (todo_id) REFERENCES todos(id) ON DELETE CASCADE"
        ");",
      );
      batch.execute("DROP TABLE IF EXISTS repeats");
      batch.execute(
        "CREATE TABLE repeats("
        "id TEXT PRIMARY KEY,"
        "todo_id TEXT NOT NULL,"
        "frequency_id TEXT NOT NULL,"
        "hour INTEGER,"
        "minute INTEGER,"
        "weekday INTEGER,"
        "day INTEGER,"
        "month INTEGER,"
        "end INTEGER,"
        "created_at INTEGER NOT NULL,"
        "updated_at INTEGER NOT NULL,"
        "FOREIGN KEY (todo_id) REFERENCES todos(id) ON DELETE CASCADE"
        ");",
      );
      await batch.commit();
      final version = await db.getVersion();
      logger.i('now database version is $version');
    }
  }

  /// tableにレコードをinsertする
  /// [json]はレコードの内容
  Future<int> create({
    required String tableName,
    required Map<String, Object?> json,
  }) async {
    final db = await database;
    return db.insert(tableName, json);
  }

  /// tableからデータを取得する
  /// [where]は id = ? のような形式にする
  /// [where]もしくは[whereArgs]がnullの場合は全件取得する
  Future<List<Map<String, Object?>>> get({
    required String tableName,
    String? where,
    List? whereArgs,
  }) async {
    final db = await database;
    if (where == null || whereArgs == null) {
      return db.query(tableName);
    }
    return db.query(
      tableName,
      where: where,
      whereArgs: whereArgs,
    );
  }

  /// tableのidに一致する[primaryKey]を指定してレコードをupdateする
  Future<int> update({
    required String tableName,
    required Map<String, Object?> json,
    required String primaryKey,
  }) async {
    final db = await database;
    return db.update(
      tableName,
      json,
      where: "id = ?",
      whereArgs: [primaryKey],
    );
  }

  /// tableのidに一致する[primaryKey]を指定してレコードを削除する
  Future<int> delete({
    required String tableName,
    required String primaryKey,
  }) async {
    final db = await database;
    var res = db.delete(tableName, where: "id = ?", whereArgs: [primaryKey]);
    return res;
  }
}

課題など

まだMVVMやAtomic Designについて理解が浅かったり、上手く書けていない部分があると思うので勉強しつつリファクタリングをしていきたいと思っています。
そして先にも書きましたが、Habit Trackerにするために機能を拡大しつつ、追々Apple Storeに公開などできたらいいなぁという気持ちです。
UIももう少し使いやすいものに変えていけたらいいなと思います。

おわりに

コードの添付ばかりで長くなってしまい、説明も稚拙なため読みにくいものになってしまいましたが、最後まで読んでくださりありがとうございます。
普段から業務でFlutterを使っていますが、アーキテクチャの実装は他の方が全てやってくださったため、実際に自分で書こうとすると色々と疑問が湧いてきたり、そもそも全然理解していなかったりと様々な発見がありました。
Flutterはどんどん好きになってきているので今後も勉強していきたいです。

また、本記事で誤りなどがあればご指摘いただけると幸いです。

Discussion