Flutter で ToDo アプリを 90 分で作る:状態管理・永続化・ダークモード対応
Flutter を使ってモダンな ToDo アプリを作ってみませんか。この記事では、実務でも使える状態管理(Provider)、データの永続化(shared_preferences)、そしてダークモード対応まで、90 分で実装できる手順を詳しくご紹介します。
初めて Flutter でアプリを作る方でも、順を追って進めれば完成できる内容になっていますので、ぜひ一緒に作ってみてください。
背景
Flutter で ToDo アプリを作る意義
Flutter は Google が開発したクロスプラットフォームフレームワークで、1 つのコードベースから iOS と Android の両方のアプリを作成できます。
ToDo アプリは基本的な CRUD(作成・読み取り・更新・削除)操作を含み、状態管理やデータ永続化などの重要な概念を学ぶのに最適な題材です。実際のアプリ開発で必要となる技術要素がコンパクトにまとまっているため、Flutter を学ぶ第一歩として理想的でしょう。
今回実装する機能の全体像
以下の図は、今回作成する ToDo アプリの機能構成を示しています。
mermaidflowchart TB
user["ユーザー"] -->|操作| ui["UI レイヤー"]
ui -->|状態変更要求| provider["Provider<br/>(状態管理)"]
provider -->|データ保存| storage["SharedPreferences<br/>(永続化)"]
provider -->|状態通知| ui
ui -->|テーマ切替| theme["ThemeMode<br/>(ダークモード)"]
theme -->|反映| ui
上図のように、UI からの操作が Provider を経由して永続化され、状態変更が UI に反映される一連のフローを実装します。
使用する主要パッケージ
今回の実装では、以下の 3 つのパッケージを中心に使用します。
| # | パッケージ名 | 用途 |
|---|---|---|
| 1 | provider | 状態管理を効率的に行う |
| 2 | shared_preferences | データをローカルに永続化する |
| 3 | intl | 日付フォーマットを扱う |
課題
Flutter 初心者が直面する 3 つの壁
Flutter でアプリを作り始めると、多くの方が以下の課題に直面します。
状態管理の複雑さ
Flutter では setState を使った簡易的な状態管理から始まりますが、アプリが大きくなるにつれて管理が煩雑になっていきます。どのウィジェットがどの状態を持つべきか、状態をどう共有するかという設計が難しいのです。
データの永続化方法
アプリを閉じても ToDo リストが残っているようにするには、データを保存する仕組みが必要です。しかし、ファイルシステムへの保存や SQLite の使用は初心者には敷居が高く感じられるでしょう。
UI のテーマ管理
ダークモードとライトモードの切り替えは、現代のアプリでは必須の機能になっています。ただし、アプリ全体のテーマを統一的に管理し、ユーザーの設定を保存する実装は、初めての方には難易度が高いかもしれません。
以下の図は、これらの課題がどのように関連しているかを示しています。
mermaidflowchart LR
challenge1["状態管理<br/>の複雑さ"] -->|影響| challenge2["データ永続化<br/>の難しさ"]
challenge2 -->|影響| challenge3["テーマ管理<br/>の実装"]
challenge1 -->|直接影響| challenge3
これら 3 つの課題は相互に関連しており、1 つずつ段階的に解決していく必要があります。
解決策
Provider パターンによる状態管理
Provider は Flutter 公式が推奨する状態管理パッケージで、シンプルかつ強力な機能を提供します。ChangeNotifier を継承したクラスで状態を管理し、notifyListeners() で UI に変更を通知する仕組みです。
SharedPreferences による永続化
shared_preferences パッケージを使うと、キーバリュー形式で簡単にデータを保存できます。複雑な設定は不要で、数行のコードでデータの読み書きが可能になります。
Material Design のテーマシステム活用
Flutter の ThemeData と ThemeMode を活用することで、アプリ全体のテーマを一元管理できます。ダークモードとライトモードの切り替えも、わずかな実装で実現可能です。
以下の図は、これら 3 つの解決策がどのように連携するかを示しています。
mermaidflowchart TB
solution1["Provider パターン"] -->|提供| stateManagement["統一的な<br/>状態管理"]
solution2["SharedPreferences"] -->|提供| persistence["簡単な<br/>データ永続化"]
solution3["ThemeData/ThemeMode"] -->|提供| theming["柔軟な<br/>テーマ管理"]
stateManagement -->|実現| app["完成した<br/>ToDo アプリ"]
persistence -->|実現| app
theming -->|実現| app
具体例
プロジェクトのセットアップ
まずは Flutter プロジェクトを作成し、必要なパッケージをインストールします。
プロジェクトの作成
ターミナルで以下のコマンドを実行して、新しい Flutter プロジェクトを作成してください。
bashflutter create todo_app
cd todo_app
パッケージのインストール
pubspec.yaml に必要な依存関係を追加します。
yamldependencies:
flutter:
sdk: flutter
provider: ^6.1.1
shared_preferences: ^2.2.2
intl: ^0.19.0
パッケージをインストールするため、以下のコマンドを実行します。
bashyarn install # または flutter pub get
データモデルの作成
ToDo アイテムを表現するモデルクラスを作成します。JSON とのやり取りもできるように、シリアライズ・デシリアライズのメソッドも実装しましょう。
Todo モデルクラス
lib/models/todo.dart を作成し、以下のコードを記述します。
dartimport 'package:intl/intl.dart';
/// ToDo アイテムを表現するモデルクラス
class Todo {
final String id;
final String title;
final String description;
final bool isCompleted;
final DateTime createdAt;
Todo({
required this.id,
required this.title,
this.description = '',
this.isCompleted = false,
required this.createdAt,
});
このクラスは、各 ToDo アイテムが持つべき基本的な情報を定義しています。
JSON からのデシリアライズ
保存されたデータから Todo オブジェクトを復元するメソッドです。
dart /// JSON からTodoオブジェクトを生成
factory Todo.fromJson(Map<String, dynamic> json) {
return Todo(
id: json['id'] as String,
title: json['title'] as String,
description: json['description'] as String? ?? '',
isCompleted: json['isCompleted'] as bool? ?? false,
createdAt: DateTime.parse(json['createdAt'] as String),
);
}
JSON へのシリアライズ
Todo オブジェクトを保存可能な JSON 形式に変換するメソッドです。
dart /// TodoオブジェクトをJSONに変換
Map<String, dynamic> toJson() {
return {
'id': id,
'title': title,
'description': description,
'isCompleted': isCompleted,
'createdAt': createdAt.toIso8601String(),
};
}
コピーメソッドの実装
既存の Todo から一部のプロパティだけを変更した新しいインスタンスを作成するメソッドです。Flutter では immutable なデータ構造が推奨されるため、このパターンがよく使われます。
dart /// 一部のプロパティを変更した新しいTodoインスタンスを作成
Todo copyWith({
String? title,
String? description,
bool? isCompleted,
}) {
return Todo(
id: id,
title: title ?? this.title,
description: description ?? this.description,
isCompleted: isCompleted ?? this.isCompleted,
createdAt: createdAt,
);
}
日付フォーマット用メソッド
作成日時を読みやすい形式で表示するためのヘルパーメソッドです。
dart /// 日付を読みやすい形式でフォーマット
String get formattedDate {
return DateFormat('yyyy年MM月dd日 HH:mm').format(createdAt);
}
}
状態管理の実装
Provider パターンを使って、ToDo リストの状態を管理するクラスを作成します。このクラスが、アプリ全体の ToDo データを一元管理する中心的な役割を果たします。
TodoProvider クラスの基本構造
lib/providers/todo_provider.dart を作成し、まずは基本的な構造を定義します。
dartimport 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/todo.dart';
/// ToDo リストの状態を管理するProvider
class TodoProvider with ChangeNotifier {
List<Todo> _todos = [];
static const String _storageKey = 'todos';
/// 外部から参照可能なTodoリスト(読み取り専用)
List<Todo> get todos => List.unmodifiable(_todos);
ChangeNotifier を継承することで、状態変更を UI に通知できるようになります。
データの初期化と読み込み
アプリ起動時に保存されたデータを読み込むメソッドです。
dart /// 保存されたTodoデータを読み込む
Future<void> loadTodos() async {
try {
final prefs = await SharedPreferences.getInstance();
final String? todosJson = prefs.getString(_storageKey);
if (todosJson != null) {
final List<dynamic> decoded = json.decode(todosJson);
_todos = decoded.map((item) => Todo.fromJson(item)).toList();
notifyListeners();
}
} catch (e) {
debugPrint('データの読み込みに失敗しました: $e');
}
}
SharedPreferences から JSON 文字列を取得し、Todo オブジェクトのリストに変換しています。
データの保存
Todo リストを SharedPreferences に保存するプライベートメソッドです。
dart /// TodoリストをSharedPreferencesに保存
Future<void> _saveTodos() async {
try {
final prefs = await SharedPreferences.getInstance();
final String encoded = json.encode(
_todos.map((todo) => todo.toJson()).toList(),
);
await prefs.setString(_storageKey, encoded);
} catch (e) {
debugPrint('データの保存に失敗しました: $e');
}
}
Todo の追加
新しい Todo アイテムを追加するメソッドです。
dart /// 新しいTodoを追加
Future<void> addTodo(String title, String description) async {
final newTodo = Todo(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: title,
description: description,
createdAt: DateTime.now(),
);
_todos.add(newTodo);
notifyListeners();
await _saveTodos();
}
一意な ID として現在時刻のミリ秒を使用し、追加後すぐに保存と通知を行います。
Todo の完了状態の切り替え
指定した Todo の完了状態をトグルするメソッドです。
dart /// Todoの完了状態を切り替え
Future<void> toggleTodoStatus(String id) async {
final index = _todos.indexWhere((todo) => todo.id == id);
if (index != -1) {
_todos[index] = _todos[index].copyWith(
isCompleted: !_todos[index].isCompleted,
);
notifyListeners();
await _saveTodos();
}
}
Todo の更新
既存の Todo の内容を更新するメソッドです。
dart /// Todoを更新
Future<void> updateTodo(String id, String title, String description) async {
final index = _todos.indexWhere((todo) => todo.id == id);
if (index != -1) {
_todos[index] = _todos[index].copyWith(
title: title,
description: description,
);
notifyListeners();
await _saveTodos();
}
}
Todo の削除
指定した Todo を削除するメソッドです。
dart /// Todoを削除
Future<void> deleteTodo(String id) async {
_todos.removeWhere((todo) => todo.id == id);
notifyListeners();
await _saveTodos();
}
フィルタリング機能
完了済み、未完了の Todo をフィルタリングして取得するゲッターです。
dart /// 完了済みのTodoのみを取得
List<Todo> get completedTodos {
return _todos.where((todo) => todo.isCompleted).toList();
}
/// 未完了のTodoのみを取得
List<Todo> get incompleteTodos {
return _todos.where((todo) => !todo.isCompleted).toList();
}
}
テーマ管理の実装
ダークモードとライトモードを切り替えられるテーマ管理機能を実装します。
ThemeProvider クラス
lib/providers/theme_provider.dart を作成します。
dartimport 'package:flutter/material.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// アプリのテーマを管理するProvider
class ThemeProvider with ChangeNotifier {
ThemeMode _themeMode = ThemeMode.light;
static const String _themeModeKey = 'theme_mode';
/// 現在のテーマモード
ThemeMode get themeMode => _themeMode;
/// ダークモードかどうか
bool get isDarkMode => _themeMode == ThemeMode.dark;
テーマの初期化
保存されたテーマ設定を読み込むメソッドです。
dart /// 保存されたテーマ設定を読み込む
Future<void> loadThemeMode() async {
try {
final prefs = await SharedPreferences.getInstance();
final isDark = prefs.getBool(_themeModeKey) ?? false;
_themeMode = isDark ? ThemeMode.dark : ThemeMode.light;
notifyListeners();
} catch (e) {
debugPrint('テーマの読み込みに失敗しました: $e');
}
}
テーマの切り替え
テーマを切り替えて保存するメソッドです。
dart /// テーマを切り替え
Future<void> toggleTheme() async {
_themeMode = _themeMode == ThemeMode.light
? ThemeMode.dark
: ThemeMode.light;
notifyListeners();
try {
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(_themeModeKey, _themeMode == ThemeMode.dark);
} catch (e) {
debugPrint('テーマの保存に失敗しました: $e');
}
}
}
UI の実装
ここからは実際のユーザーインターフェースを構築していきます。Provider を使って状態を表示し、ユーザーの操作を状態変更に反映させる仕組みを作ります。
メインアプリの設定
lib/main.dart を編集し、Provider の設定とテーマの適用を行います。
dartimport 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/todo_provider.dart';
import 'providers/theme_provider.dart';
import 'screens/home_screen.dart';
void main() {
runApp(const MyApp());
}
MultiProvider によるプロバイダーの提供
複数の Provider を一度に提供するため、MultiProvider を使用します。
dartclass MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => TodoProvider()..loadTodos()),
ChangeNotifierProvider(create: (_) => ThemeProvider()..loadThemeMode()),
],
child: const AppContent(),
);
}
}
create 関数内で ..loadTodos() と ..loadThemeMode() を呼び出すことで、アプリ起動時にデータを自動的に読み込みます。
MaterialApp の設定
テーマを適用した MaterialApp を構築します。
dartclass AppContent extends StatelessWidget {
const AppContent({super.key});
@override
Widget build(BuildContext context) {
final themeProvider = Provider.of<ThemeProvider>(context);
return MaterialApp(
title: 'ToDo アプリ',
debugShowCheckedModeBanner: false,
themeMode: themeProvider.themeMode,
theme: _buildLightTheme(),
darkTheme: _buildDarkTheme(),
home: const HomeScreen(),
);
}
ライトテーマの定義
明るいテーマの配色を定義します。
dart /// ライトテーマの定義
ThemeData _buildLightTheme() {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.light,
),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
);
}
ダークテーマの定義
暗いテーマの配色を定義します。
dart /// ダークテーマの定義
ThemeData _buildDarkTheme() {
return ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.blue,
brightness: Brightness.dark,
),
appBarTheme: const AppBarTheme(
centerTitle: true,
elevation: 0,
),
);
}
}
Material 3 デザインを使用することで、モダンで美しい UI が自動的に適用されます。
ホーム画面の構築
lib/screens/home_screen.dart を作成し、ToDo リストを表示するメイン画面を実装します。
dartimport 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/todo_provider.dart';
import '../providers/theme_provider.dart';
import '../widgets/todo_list_item.dart';
import '../widgets/add_todo_dialog.dart';
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key});
@override
State<HomeScreen> createState() => _HomeScreenState();
}
タブによるフィルタリング
完了済み、未完了、全てのタブを切り替えられるようにします。
dartclass _HomeScreenState extends State<HomeScreen>
with SingleTickerProviderStateMixin {
late TabController _tabController;
@override
void initState() {
super.initState();
_tabController = TabController(length: 3, vsync: this);
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
Scaffold の構築
AppBar、TabBar、FloatingActionButton を配置します。
dart @override
Widget build(BuildContext context) {
final themeProvider = Provider.of<ThemeProvider>(context);
return Scaffold(
appBar: AppBar(
title: const Text('ToDo アプリ'),
actions: [
IconButton(
icon: Icon(
themeProvider.isDarkMode
? Icons.light_mode
: Icons.dark_mode,
),
onPressed: () => themeProvider.toggleTheme(),
tooltip: 'テーマ切り替え',
),
],
bottom: TabBar(
controller: _tabController,
tabs: const [
Tab(text: '全て'),
Tab(text: '未完了'),
Tab(text: '完了済み'),
],
),
),
body: TabBarView(
controller: _tabController,
children: const [
_TodoListView(filter: _TodoFilter.all),
_TodoListView(filter: _TodoFilter.incomplete),
_TodoListView(filter: _TodoFilter.completed),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddTodoDialog(context),
child: const Icon(Icons.add),
tooltip: '新しいTodoを追加',
),
);
}
AppBar の actions に配置したアイコンボタンで、ワンタップでテーマを切り替えられます。
Todo 追加ダイアログの表示
新しい Todo を追加するダイアログを表示するメソッドです。
dart /// Todo追加ダイアログを表示
void _showAddTodoDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => const AddTodoDialog(),
);
}
}
フィルタリング列挙型
タブの種類を表す列挙型です。
dart/// Todoリストのフィルタリング種別
enum _TodoFilter {
all,
incomplete,
completed,
}
TodoListView ウィジェット
フィルターに応じた Todo リストを表示するウィジェットです。
dart/// フィルタリングされたTodoリストを表示するウィジェット
class _TodoListView extends StatelessWidget {
final _TodoFilter filter;
const _TodoListView({required this.filter});
@override
Widget build(BuildContext context) {
return Consumer<TodoProvider>(
builder: (context, todoProvider, child) {
final todos = _getFilteredTodos(todoProvider);
if (todos.isEmpty) {
return _buildEmptyState();
}
return ListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
return TodoListItem(todo: todos[index]);
},
);
},
);
}
Consumer ウィジェットを使うことで、TodoProvider の変更を自動的に検知して UI を更新します。
フィルタリングロジック
選択されたタブに応じて表示する Todo をフィルタリングします。
dart /// フィルターに応じたTodoリストを取得
List<dynamic> _getFilteredTodos(TodoProvider provider) {
switch (filter) {
case _TodoFilter.all:
return provider.todos;
case _TodoFilter.incomplete:
return provider.incompleteTodos;
case _TodoFilter.completed:
return provider.completedTodos;
}
}
空状態の表示
Todo が 1 つもない場合のメッセージを表示します。
dart /// Todoが空の場合の表示
Widget _buildEmptyState() {
String message;
IconData icon;
switch (filter) {
case _TodoFilter.all:
message = 'Todoがありません\n右下のボタンから追加してください';
icon = Icons.inbox_outlined;
break;
case _TodoFilter.incomplete:
message = '未完了のTodoはありません';
icon = Icons.check_circle_outline;
break;
case _TodoFilter.completed:
message = '完了したTodoはありません';
icon = Icons.pending_outlined;
break;
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 80, color: Colors.grey),
const SizedBox(height: 16),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16, color: Colors.grey),
),
],
),
);
}
}
Todo リストアイテムの実装
各 Todo を表示するリストアイテムウィジェットを作成します。
TodoListItem ウィジェット
lib/widgets/todo_list_item.dart を作成します。
dartimport 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/todo.dart';
import '../providers/todo_provider.dart';
import 'edit_todo_dialog.dart';
/// Todoリストの各アイテムを表示するウィジェット
class TodoListItem extends StatelessWidget {
final Todo todo;
const TodoListItem({
super.key,
required this.todo,
});
Card による表示
Card ウィジェットを使って、各 Todo を見やすく表示します。
dart @override
Widget build(BuildContext context) {
final todoProvider = Provider.of<TodoProvider>(context, listen: false);
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListTile(
leading: Checkbox(
value: todo.isCompleted,
onChanged: (_) => todoProvider.toggleTodoStatus(todo.id),
),
title: Text(
todo.title,
style: TextStyle(
decoration: todo.isCompleted
? TextDecoration.lineThrough
: null,
color: todo.isCompleted
? Colors.grey
: null,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (todo.description.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
todo.description,
style: TextStyle(
color: todo.isCompleted ? Colors.grey : null,
),
),
],
const SizedBox(height: 4),
Text(
todo.formattedDate,
style: const TextStyle(fontSize: 12, color: Colors.grey),
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.edit, color: Colors.blue),
onPressed: () => _showEditDialog(context),
tooltip: '編集',
),
IconButton(
icon: const Icon(Icons.delete, color: Colors.red),
onPressed: () => _showDeleteConfirmation(context, todoProvider),
tooltip: '削除',
),
],
),
isThreeLine: todo.description.isNotEmpty,
),
);
}
完了済みの Todo にはチェックマークと取り消し線が表示されます。
編集ダイアログの表示
Todo を編集するダイアログを表示します。
dart /// 編集ダイアログを表示
void _showEditDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => EditTodoDialog(todo: todo),
);
}
削除確認ダイアログ
削除前に確認ダイアログを表示します。
dart /// 削除確認ダイアログを表示
void _showDeleteConfirmation(
BuildContext context,
TodoProvider provider,
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('削除の確認'),
content: Text('「${todo.title}」を削除しますか?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('キャンセル'),
),
TextButton(
onPressed: () {
provider.deleteTodo(todo.id);
Navigator.pop(context);
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('削除'),
),
],
),
);
}
}
誤って削除してしまうことを防ぐため、確認ダイアログを挟んでいます。
Todo 追加ダイアログの実装
新しい Todo を追加するためのダイアログを作成します。
AddTodoDialog ウィジェット
lib/widgets/add_todo_dialog.dart を作成します。
dartimport 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/todo_provider.dart';
/// Todo追加用ダイアログ
class AddTodoDialog extends StatefulWidget {
const AddTodoDialog({super.key});
@override
State<AddTodoDialog> createState() => _AddTodoDialogState();
}
フォームの状態管理
TextEditingController と GlobalKey を使ってフォームを管理します。
dartclass _AddTodoDialogState extends State<AddTodoDialog> {
final _formKey = GlobalKey<FormState>();
final _titleController = TextEditingController();
final _descriptionController = TextEditingController();
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
ダイアログ UI の構築
AlertDialog 内にフォームを配置します。
dart @override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('新しいTodoを追加'),
content: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'タイトル',
hintText: 'やるべきことを入力',
border: OutlineInputBorder(),
),
autofocus: true,
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'タイトルを入力してください';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: '説明(任意)',
hintText: '詳細を入力',
border: OutlineInputBorder(),
),
maxLines: 3,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('キャンセル'),
),
FilledButton(
onPressed: _handleSubmit,
child: const Text('追加'),
),
],
);
}
タイトルには必須のバリデーションを設定し、空欄での追加を防止しています。
送信処理
フォームのバリデーションを行い、Todo を追加します。
dart /// フォーム送信処理
void _handleSubmit() {
if (_formKey.currentState!.validate()) {
final todoProvider = Provider.of<TodoProvider>(
context,
listen: false,
);
todoProvider.addTodo(
_titleController.text.trim(),
_descriptionController.text.trim(),
);
Navigator.pop(context);
}
}
}
Todo 編集ダイアログの実装
既存の Todo を編集するためのダイアログを作成します。
EditTodoDialog ウィジェット
lib/widgets/edit_todo_dialog.dart を作成します。
dartimport 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/todo.dart';
import '../providers/todo_provider.dart';
/// Todo編集用ダイアログ
class EditTodoDialog extends StatefulWidget {
final Todo todo;
const EditTodoDialog({
super.key,
required this.todo,
});
@override
State<EditTodoDialog> createState() => _EditTodoDialogState();
}
初期値の設定
編集対象の Todo の内容を初期値として設定します。
dartclass _EditTodoDialogState extends State<EditTodoDialog> {
final _formKey = GlobalKey<FormState>();
late final TextEditingController _titleController;
late final TextEditingController _descriptionController;
@override
void initState() {
super.initState();
_titleController = TextEditingController(text: widget.todo.title);
_descriptionController = TextEditingController(
text: widget.todo.description,
);
}
@override
void dispose() {
_titleController.dispose();
_descriptionController.dispose();
super.dispose();
}
編集フォームの UI
追加ダイアログと同様のフォームを使用します。
dart @override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Todoを編集'),
content: Form(
key: _formKey,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
TextFormField(
controller: _titleController,
decoration: const InputDecoration(
labelText: 'タイトル',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'タイトルを入力してください';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: _descriptionController,
decoration: const InputDecoration(
labelText: '説明',
border: OutlineInputBorder(),
),
maxLines: 3,
),
],
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('キャンセル'),
),
FilledButton(
onPressed: _handleSubmit,
child: const Text('保存'),
),
],
);
}
更新処理
変更内容を保存して Provider に反映します。
dart /// フォーム送信処理
void _handleSubmit() {
if (_formKey.currentState!.validate()) {
final todoProvider = Provider.of<TodoProvider>(
context,
listen: false,
);
todoProvider.updateTodo(
widget.todo.id,
_titleController.text.trim(),
_descriptionController.text.trim(),
);
Navigator.pop(context);
}
}
}
アプリの動作確認
ここまでのコードで、基本的な機能がすべて揃いました。以下のコマンドでアプリを起動してみましょう。
bashflutter run
エミュレーターまたは実機でアプリが起動し、以下の機能が動作することを確認できます。
| # | 機能 | 動作内容 |
|---|---|---|
| 1 | Todo の追加 | 右下の「+」ボタンから新規追加 |
| 2 | Todo の完了 | チェックボックスをタップして完了状態を切り替え |
| 3 | Todo の編集 | 編集ボタンから内容を変更 |
| 4 | Todo の削除 | 削除ボタンで確認後に削除 |
| 5 | フィルタリング | タブで全て/未完了/完了済みを切り替え |
| 6 | ダークモード | 右上のアイコンでテーマを切り替え |
| 7 | データ永続化 | アプリを再起動してもデータが保持される |
エラーハンドリングの追加
実装した基本機能にエラーハンドリングを追加することで、より堅牢なアプリにしましょう。
ネットワークエラーへの対応
SharedPreferences の読み書きは通常ローカルストレージへのアクセスですが、稀に失敗する可能性があります。
lib/providers/todo_provider.dart の各メソッドには既に try-catch を実装していますが、ユーザーにエラーを通知する仕組みを追加できます。
dart /// エラー状態を保持
String? _errorMessage;
String? get errorMessage => _errorMessage;
/// エラーメッセージをクリア
void clearError() {
_errorMessage = null;
notifyListeners();
}
データ読み込みエラーの処理
読み込み時のエラーをユーザーに通知できるようにします。
dart /// 保存されたTodoデータを読み込む(エラー通知付き)
Future<void> loadTodos() async {
try {
final prefs = await SharedPreferences.getInstance();
final String? todosJson = prefs.getString(_storageKey);
if (todosJson != null) {
final List<dynamic> decoded = json.decode(todosJson);
_todos = decoded.map((item) => Todo.fromJson(item)).toList();
_errorMessage = null;
}
} catch (e) {
_errorMessage = 'データの読み込みに失敗しました';
debugPrint('Error loading todos: $e');
} finally {
notifyListeners();
}
}
エラーメッセージの表示
ホーム画面でエラーメッセージを表示します。
lib/screens/home_screen.dart の Scaffold の body 部分を以下のように修正できます。
dart body: Column(
children: [
// エラーメッセージの表示
Consumer<TodoProvider>(
builder: (context, todoProvider, child) {
if (todoProvider.errorMessage != null) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
color: Colors.red.shade100,
child: Row(
children: [
const Icon(Icons.error_outline, color: Colors.red),
const SizedBox(width: 8),
Expanded(
child: Text(
todoProvider.errorMessage!,
style: const TextStyle(color: Colors.red),
),
),
IconButton(
icon: const Icon(Icons.close, color: Colors.red),
onPressed: () => todoProvider.clearError(),
),
],
),
);
}
return const SizedBox.shrink();
},
),
// 既存のTabBarView
Expanded(
child: TabBarView(
controller: _tabController,
children: const [
_TodoListView(filter: _TodoFilter.all),
_TodoListView(filter: _TodoFilter.incomplete),
_TodoListView(filter: _TodoFilter.completed),
],
),
),
],
),
これにより、データの読み込みや保存に失敗した場合、ユーザーにわかりやすくエラーが通知されます。
パフォーマンス最適化
アプリのパフォーマンスをさらに向上させるためのテクニックをいくつか紹介します。
const コンストラクタの活用
既に実装コードで使用していますが、変更されないウィジェットには const を付けることで、Flutter が再ビルドをスキップできます。
dart// 良い例
const Text('Todo アプリ')
// 避けるべき例
Text('Todo アプリ')
Consumer の範囲の最小化
Provider の変更を監視する Consumer は、必要最小限の範囲に留めることが重要です。
dart// 良い例:必要な部分だけConsumerで囲む
ListTile(
title: Consumer<TodoProvider>(
builder: (context, provider, child) {
return Text(provider.todos.length.toString());
},
),
)
// 避けるべき例:不必要に大きな範囲をConsumerで囲む
Consumer<TodoProvider>(
builder: (context, provider, child) {
return Scaffold(/* ... */);
},
)
ListView.builder の活用
既に実装していますが、ListView.builder は画面に表示される分だけウィジェットを生成するため、大量のアイテムでも効率的です。
dartListView.builder(
itemCount: todos.length,
itemBuilder: (context, index) {
return TodoListItem(todo: todos[index]);
},
)
テストの追加
アプリの品質を保証するため、基本的なテストを追加しましょう。
ユニットテストの例
test/providers/todo_provider_test.dart を作成します。
dartimport 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:todo_app/providers/todo_provider.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
group('TodoProvider Tests', () {
late TodoProvider todoProvider;
setUp(() async {
// SharedPreferencesのモックを初期化
SharedPreferences.setMockInitialValues({});
todoProvider = TodoProvider();
await todoProvider.loadTodos();
});
Todo 追加のテスト
Todo が正しく追加されるかをテストします。
dart test('Todoを追加できる', () async {
// 実行
await todoProvider.addTodo('テストTodo', 'テスト説明');
// 検証
expect(todoProvider.todos.length, 1);
expect(todoProvider.todos.first.title, 'テストTodo');
expect(todoProvider.todos.first.description, 'テスト説明');
expect(todoProvider.todos.first.isCompleted, false);
});
Todo 完了状態のテスト
完了状態の切り替えが正しく動作するかをテストします。
dart test('Todoの完了状態を切り替えられる', () async {
// 準備
await todoProvider.addTodo('テストTodo', '');
final todoId = todoProvider.todos.first.id;
// 実行
await todoProvider.toggleTodoStatus(todoId);
// 検証
expect(todoProvider.todos.first.isCompleted, true);
// 再度切り替え
await todoProvider.toggleTodoStatus(todoId);
expect(todoProvider.todos.first.isCompleted, false);
});
Todo 削除のテスト
Todo が正しく削除されるかをテストします。
dart test('Todoを削除できる', () async {
// 準備
await todoProvider.addTodo('削除テスト', '');
final todoId = todoProvider.todos.first.id;
expect(todoProvider.todos.length, 1);
// 実行
await todoProvider.deleteTodo(todoId);
// 検証
expect(todoProvider.todos.length, 0);
});
});
}
テストの実行
以下のコマンドでテストを実行できます。
bashflutter test
すべてのテストが成功すれば、基本機能が正しく実装されていることが確認できます。
まとめ
この記事では、Flutter を使って 90 分で ToDo アプリを作成する手順を詳しくご紹介しました。
Provider による状態管理、SharedPreferences によるデータの永続化、そしてダークモード対応という、実務でも必要となる重要な技術要素を実装できましたね。
実装した主要機能
| # | 機能 | 使用技術 |
|---|---|---|
| 1 | 状態管理 | Provider + ChangeNotifier |
| 2 | データ永続化 | SharedPreferences + JSON シリアライズ |
| 3 | テーマ管理 | ThemeData + ThemeMode |
| 4 | CRUD 操作 | 追加・編集・削除・完了切り替え |
| 5 | フィルタリング | タブによる表示切り替え |
| 6 | エラーハンドリング | try-catch + ユーザー通知 |
| 7 | ユニットテスト | flutter_test パッケージ |
さらなる改善案
今回実装した ToDo アプリは基本的な機能を備えていますが、さらに拡張できる要素もあります。
カテゴリーや優先度の追加、期限の設定、リマインダー機能、バックエンドとの同期など、アイデアは無限に広がるでしょう。また、Riverpod や Bloc などの他の状態管理ライブラリを試してみるのも良い学習になります。
学んだ重要な概念
Provider パターンを使うことで、状態の管理が一元化され、コードの見通しが良くなりました。SharedPreferences により、複雑な設定なしにデータを永続化できることも実感できたのではないでしょうか。
Flutter の宣言的 UI と状態管理の組み合わせは、最初は戸惑うかもしれませんが、慣れてくると非常に強力な開発体験を提供してくれます。
この記事で作成したアプリをベースに、ぜひあなた独自の機能を追加して、より便利な ToDo アプリに進化させてみてください。Flutter でのアプリ開発の楽しさを、存分に味わっていただければ幸いです。
関連リンク
articleFlutter で ToDo アプリを 90 分で作る:状態管理・永続化・ダークモード対応
articleFlutter の状態管理を設計する:Riverpod/Bloc/Provider/Redux の実務指針
articleFlutter ウィジェット早見表:レイアウト・入力・ナビゲーションを 1 枚で把握
articleFlutter 開発環境の最短構築:macOS/Windows/Linux 別インストール完全ガイド
articleFlutter とは?2025 年版:仕組み・強み・向いているプロダクトを徹底解説
articleDeno とは?Node.js との違い・強み・ユースケースを最新整理
articlegpt-oss アーキテクチャを分解図で理解する:推論ランタイム・トークナイザ・サービング層の役割
articlePHP で社内業務自動化:CSV→DB 取込・定期バッチ・Slack 通知の実例
articleGPT-5 × Cloudflare Workers/Edge:低遅延サーバーレスのスターターガイド
articleNotebookLM と Notion AI/ChatGPT の比較:根拠提示とソース管理の違い
articleFlutter で ToDo アプリを 90 分で作る:状態管理・永続化・ダークモード対応
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来