Flutter Riverpod+SQLiteを使ってTodoアプリを作ってみた
作ったもの
超絶シンプルなTodoアプリです。
SQLiteの勉強がてら作りました。
Riverpod・Flutter hooks・StateNotifier・freezedを使用しています。
UIは適当です。
一覧
追加
クローン後、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
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
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,
('') String title,
(name: 'is_done') (0) int isDone,
}) = _Todo;
factory Todo.fromJson(Map<String, dynamic> json) => _$TodoFromJson(json);
}
freezedを使って、immutable(不変)にします。
isDone
は、boolean型にしたかったけど、SQLiteがboolean型に対応していないため、
0: 未完了 1:完了
とすることに(ここはもっと上手いやり方があるはず…)。
Database
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
)
''');
}
}
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を実行します。
他にも種類があるようです。こちらに記載されています。
Repository
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
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({
([]) List<Todo> todos,
}) = _TodoState;
}
Todoリストの状態を管理しています。
こちらもfreezedを使って、immutable(不変)にしています。
ViewModel
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
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,
),
);
}
}
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アプリはもちろん、カレンダーアプリや家計簿アプリなんかも作れそうです。
参考にさせていただいた記事
GitHub
クローン後、rootディレクトリで
flutter pub run build_runner build --delete-conflicting-outputs
flutter run
Discussion