こんにちは、アプリ開発者のテルです!
「FlutterでのTodoアプリの作り方がわからない」とお悩みではないでしょうか?
本記事ではそんな悩みを解決していきます!
- FlutterでのTodoアプリの作り方がわかるようになる
- Riverpod + freezed + Driftの使い方がわかるようになる
- コードを公開しているので、自分の環境で確かめることができる
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で完結できるので非常にスッキリします。
ぜひ今回のサンプルを参考に、自分で色々と試してみてください。
最後までご覧いただきありがとうございました。ではまた!