🙆‍♀️

Flutter Riverpod+SQLiteを使ってTodoアプリを作ってみた

12 min read

作ったもの

超絶シンプルなTodoアプリです。
SQLiteの勉強がてら作りました。

Riverpod・Flutter hooks・StateNotifier・freezedを使用しています。
UIは適当です。


一覧


追加

https://github.com/yumiba109/riverpod_sqlite_sample

クローン後、rootディレクトリで
flutter pub run build_runner build --delete-conflicting-outputs
flutter run
これでアプリが起動します。

実装

ディレクトリ構造

├── lib
│   ├── main.dart
│   ├── views
│   │   ├── todo_add_page.dart
│   │   └── todo_list_page.dart
│   ├── viewModels
│   │   └── todo_view_model.dart
│   ├── states
│   │   └── todo_state.dart
│   ├── repositories
│   │   └── todo_repository.dart
│   ├── models
│   │   └── todo.dart
│   └── databases
│       ├── app_database.dart
│       └── todo_database.dart
└── pubspec.yaml

MVVM + Repositoryパターンで実装しています。

バージョン

Flutter:2.3.0-24.1.pre
Dart:2.14.0
Xcode:12.4

Package

pubspec.yaml
name: riverpod_sqlite_sample
description: A new Flutter project.

publish_to: 'none'

version: 1.0.0+1

environment:
  sdk: ">=2.12.0 <3.0.0"

dependencies:
  flutter:
    sdk: flutter

  sqflite: ^2.0.0+4 //追加
  path: ^1.8.0 //追加

  hooks_riverpod: ^0.14.0+4 //追加
  state_notifier: ^0.7.0 //追加

  freezed_annotation: ^0.14.3 //追加
  json_serializable: ^4.1.4 //追加

  flutter_slidable: ^0.6.0 //追加

  cupertino_icons: ^1.0.2

dev_dependencies:
  flutter_test:
    sdk: flutter

  freezed: ^0.14.2 //追加
  build_runner: ^2.1.1 //追加

  flutter_lints: ^1.0.0

flutter:

  uses-material-design: true

Model

models/todo.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'todo.freezed.dart';
part 'todo.g.dart';


abstract class Todo with _$Todo {
  const factory Todo({
    int? id,
    @Default('') String title,
    @JsonKey(name: 'is_done') @Default(0) int isDone,
  }) = _Todo;

  factory Todo.fromJson(Map<String, dynamic> json) => _$TodoFromJson(json);
}

freezedを使って、immutable(不変)にします。
isDoneは、boolean型にしたかったけど、SQLiteがboolean型に対応していないため、
0: 未完了 1:完了
とすることに(ここはもっと上手いやり方があるはず…)。

Database

databases/app_database.dart
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';

class AppDatabase {
  late Database _database;

  Future<Database> get database async {
    _database = await _initDB();
    return _database;
  }

  Future<Database> _initDB() async {
    String path = join(await getDatabasesPath(), 'todo.db');

    return await openDatabase(
      path,
      version: 1,
      onCreate: _createTable,
    );
  }

  Future<void> _createTable(Database db, int version) async {
    await db.execute('''
      CREATE TABLE todos(
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT,
        is_done BOOLEAN
      )
    ''');
  }
}
databases/todo_database.dart
import 'package:riverpod_sqlite_sample/databases/app_database.dart';
import 'package:riverpod_sqlite_sample/models/todo.dart';
import 'package:sqflite/sqflite.dart';

class TodoDatabase extends AppDatabase {
  final String _tableName = 'todos';

  Future<List<Todo>> getTodos() async {
    final db = await database;
    final maps = await db.query(
      _tableName,
      orderBy: 'id DESC',
    );

    if (maps.isEmpty) return [];

    return maps.map((map) => Todo.fromJson(map)).toList();
  }

  Future<Todo> insert(Todo todo) async {
    final db = await database;

    final id = await db.insert(
      _tableName,
      todo.toJson(),
      conflictAlgorithm: ConflictAlgorithm.replace,
    );

    return todo.copyWith(
      id: id,
    );
  }

  Future update(Todo todo) async {
    final db = await database;

    return await db.update(
      _tableName,
      todo.toJson(),
      where: 'id = ?',
      whereArgs: [todo.id],
      conflictAlgorithm: ConflictAlgorithm.replace,
    );
  }

  Future delete(int id) async {
    final db = await database;

    return await db.delete(
      _tableName,
      where: 'id = ?',
      whereArgs: [id],
    );
  }
}

app_database.dartで初回起動時にtodosテーブルを作成するようにし、todo_database.dartでextendsして呼び出すようにしています。

conflictAlgorithmは、insertやupdate時にコンフリクトが発生した場合に、どのように処理するかを定義するものです。
ConflictAlgorithm.replaceは競合している対象のレコードを削除してからSQLを実行します。

他にも種類があるようです。こちらに記載されています。

https://flutter.ctrnost.com/logic/sqlite/#データの挿入

Repository

repositories/todo_repository.dart
import 'package:riverpod_sqlite_sample/databases/todo_database.dart';
import 'package:riverpod_sqlite_sample/models/todo.dart';

class TodoRepository {
  final _db = TodoDatabase();

  final TodoDatabase _todoDatabase;

  TodoRepository(this._todoDatabase);

  Future<List<Todo>> getTodos() async {
    return _todoDatabase.getTodos();
  }

  Future<Todo> addTodo(Todo todo) async {
    return _todoDatabase.insert(todo);
  }

  Future<void> updateTodo(Todo todo) async {
    return _todoDatabase.update(todo);
  }

  Future<void> deleteTodo(int id) async {
    return _todoDatabase.delete(id);
  }
}

ViewModelから呼び出されて、DBにアクセスします。

State

states/todo_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:riverpod_sqlite_sample/models/todo.dart';

part 'todo_state.freezed.dart';


abstract class TodoState with _$TodoState {
  const factory TodoState({
    @Default([]) List<Todo> todos,
  }) = _TodoState;
}

Todoリストの状態を管理しています。
こちらもfreezedを使って、immutable(不変)にしています。

ViewModel

viewModels/todo_view_model.dart
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_sqlite_sample/databases/todo_database.dart';
import 'package:riverpod_sqlite_sample/models/todo.dart';
import 'package:riverpod_sqlite_sample/repositories/todo_repository.dart';
import 'package:riverpod_sqlite_sample/states/todo_state.dart';

final todoViewModelProvider = StateNotifierProvider(
  (ref) => TodoViewModelProvider(
    ref.read,
    TodoRepository(TodoDatabase()),
  ),
);

class TodoViewModelProvider extends StateNotifier<TodoState> {
  TodoViewModelProvider(this._reader, this._todoRepository)
      : super(const TodoState()) {
    getTodos();
  }

  final Reader _reader;
  final TodoRepository _todoRepository;

  Future<void> addTodo(String title) async {
    final todo = await _todoRepository.addTodo(Todo(
      title: title,
      isDone: 0,
    ));

    state = state.copyWith(
      todos: [todo, ...state.todos],
    );
  }

  Future<void> getTodos() async {
    final todos = await _todoRepository.getTodos();

    state = state.copyWith(
      todos: todos,
    );
  }

  Future<void> changeStatus(Todo todo, int value) async {
    final newTodo = todo.copyWith(
      isDone: value,
    );

    await _todoRepository.updateTodo(newTodo);

    final todos = state.todos
        .map((todo) => todo.id == newTodo.id ? newTodo : todo)
        .toList();

    state = state.copyWith(
      todos: todos,
    );
  }

  Future<void> deleteTodo(int todoId) async {
    await _todoRepository.deleteTodo(todoId);

    final todos = state.todos.where((todo) => todo.id != todoId).toList();

    state = state.copyWith(
      todos: todos,
    );
  }
}

View

views/todo_list_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_sqlite_sample/models/todo.dart';
import 'package:riverpod_sqlite_sample/viewModels/todo_view_model.dart';
import 'package:riverpod_sqlite_sample/views/todo_add_page.dart';

class TodoListPage extends HookWidget {
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(),
      body: _todoList(),
      floatingActionButton: FloatingActionButton(
        child: const Icon(Icons.edit),
        backgroundColor: Theme.of(context).primaryColor,
        onPressed: () async {
          Navigator.of(context, rootNavigator: true).push(
            MaterialPageRoute(
              builder: (context) {
                return TodoAddPage();
              },
              fullscreenDialog: true,
            ),
          );
        },
      ),
    );
  }

  Widget _todoList() {
    final todoViewModel = useProvider(todoViewModelProvider.notifier);
    final todoState = useProvider(todoViewModelProvider);

    return ListView.builder(
      itemCount: todoState.todos.length,
      itemBuilder: (BuildContext context, int index) {
        final todo = todoState.todos[index];

        return _todoItem(todo, index, todoViewModel);
      },
    );
  }

  Widget _todoItem(Todo todo, int index, TodoViewModelProvider todoViewModel) {
    return Slidable(
      actionPane: const SlidableScrollActionPane(),
      secondaryActions: [
        IconSlideAction(
          caption: '削除',
          color: Colors.red,
          icon: Icons.delete,
          onTap: () async {
            await todoViewModel.deleteTodo(todo.id!);
          },
        ),
      ],
      child: CheckboxListTile(
        title: Text(
          todo.title,
          style: TextStyle(
              decoration: todo.isDone == 1
                  ? TextDecoration.lineThrough
                  : TextDecoration.none),
        ),
        value: todo.isDone == 1 ? true : false,
        onChanged: (value) {
          todoViewModel.changeStatus(todo, value! ? 1 : 0);
        },
        controlAffinity: ListTileControlAffinity.leading,
      ),
    );
  }
}
views/todo_add_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_sqlite_sample/viewModels/todo_view_model.dart';

class TodoAddPage extends HookWidget {
  
  Widget build(BuildContext context) {
    final todoViewModel = useProvider(todoViewModelProvider.notifier);

    final _titleController = TextEditingController();

    return Scaffold(
      appBar: AppBar(),
      body: Column(
        children: <Widget>[
          TextField(
            controller: _titleController,
          ),
          ElevatedButton(
            onPressed: () async {
              await todoViewModel.addTodo(_titleController.text);

              Navigator.pop(context);
            },
            child: const Text('追加'),
          ),
        ],
      ),
    );
  }
}

終わりに

今回は、Riverpod + Flutter hooks + StateNotifier + freezedを使って、簡単なQiitaアプリを作成しました。

これでTodoアプリはもちろん、カレンダーアプリや家計簿アプリなんかも作れそうです。

参考にさせていただいた記事

https://zenn.dev/yass97/articles/abe3dd8f9b2c1b
https://flutter.ctrnost.com/logic/sqlite

GitHub

https://github.com/yumiba109/riverpod_sqlite_sample

クローン後、rootディレクトリで
flutter pub run build_runner build --delete-conflicting-outputs
flutter run

Discussion

ログインするとコメントできます