T-CREATOR

Flutter で ToDo アプリを 90 分で作る:状態管理・永続化・ダークモード対応

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 つのパッケージを中心に使用します。

#パッケージ名用途
1provider状態管理を効率的に行う
2shared_preferencesデータをローカルに永続化する
3intl日付フォーマットを扱う

課題

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

エミュレーターまたは実機でアプリが起動し、以下の機能が動作することを確認できます。

#機能動作内容
1Todo の追加右下の「+」ボタンから新規追加
2Todo の完了チェックボックスをタップして完了状態を切り替え
3Todo の編集編集ボタンから内容を変更
4Todo の削除削除ボタンで確認後に削除
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
4CRUD 操作追加・編集・削除・完了切り替え
5フィルタリングタブによる表示切り替え
6エラーハンドリングtry-catch + ユーザー通知
7ユニットテストflutter_test パッケージ

さらなる改善案

今回実装した ToDo アプリは基本的な機能を備えていますが、さらに拡張できる要素もあります。

カテゴリーや優先度の追加、期限の設定、リマインダー機能、バックエンドとの同期など、アイデアは無限に広がるでしょう。また、Riverpod や Bloc などの他の状態管理ライブラリを試してみるのも良い学習になります。

学んだ重要な概念

Provider パターンを使うことで、状態の管理が一元化され、コードの見通しが良くなりました。SharedPreferences により、複雑な設定なしにデータを永続化できることも実感できたのではないでしょうか。

Flutter の宣言的 UI と状態管理の組み合わせは、最初は戸惑うかもしれませんが、慣れてくると非常に強力な開発体験を提供してくれます。

この記事で作成したアプリをベースに、ぜひあなた独自の機能を追加して、より便利な ToDo アプリに進化させてみてください。Flutter でのアプリ開発の楽しさを、存分に味わっていただければ幸いです。

関連リンク