【Flutter】TODO / タスク管理アプリを作成する方法

## 1. はじめに

この記事では、Flutterを使って基本的な機能を持ったTODO / タスク管理アプリを作成します。Flutterの基礎を学びながら、簡単なアプリを作ってみましょう。

## 2. タスク管理アプリの主な機能:

1. **タスクの追加（作成）**:
画面右下の「＋」ボタンをタップすると、ダイアログが表示され、タスク名と締め切りを入力してタスクを追加できます。

2. **タスクの編集**:
リスト上の任意のタスクをタップすると、編集ダイアログが表示され、タスク名や締め切りを変更することができます。

3. **タスクの削除**:
タスクを左または右にスワイプすると削除されます。

4. **タスクの完了／未完了の管理**:
リストの右側にあるチェックボックスをタップして、タスクの完了状態を切り替え、完了したタスクは削除します。

5. **タスクのソート**
タスクの並び順として、締め切り日 (deadline) が近い順にタスクを並べています。

6. **端末のストレージへのタスクの書き込み・読み込み**
端末のローカルストレージにタスクを保存し、アプリが再起動されてもタスクを保持（永続化）することができます。

## 3. 実装

日付をフォーマットするために、`intl`パッケージをインストールします。

```bash
flutter pub add intl
```
アプリを再起動してもタスクを保持するため、`shared_preferences`パッケージをインストールします。

```bash
flutter pub add shared_preferences
```

`main.dart`を以下の通り実装します。

```dart
import 'package:flutter/material.dart';
import 'package:intl/intl.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'dart:convert';

void main() {
  runApp(TaskManagerApp());
}

class TaskManagerApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Task Manager',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: TaskManagerScreen(),
    );
  }
}

/* Taskクラスの定義
・「タスク名」「締め切り」「完了／未完了」の要素を含む
・ name：タスク名
・ deadline：締め切り日
・ isCompleted：完了／未完了の状態（初期値は`false`）
*/
class Task {
  String name;
  DateTime deadline;
  bool isCompleted;

  Task({required this.name, required this.deadline, this.isCompleted = false});

  // JSONへ変換するためのメソッド(shared_preferenceへの書き込み時に使用)
  Map<String, dynamic> toJson() {
    return {
      'name': name,
      'deadline': deadline.toIso8601String(),
      'isCompleted': isCompleted,
    };
  }

  // JSONからTaskオブジェクトを作成するためのメソッド(shared_preferenceからの読み込み時に使用)
  factory Task.fromJson(Map<String, dynamic> json) {
    return Task(
      name: json['name'],
      deadline: DateTime.parse(json['deadline']),
      isCompleted: json['isCompleted'],
    );
  }
}

class TaskManagerScreen extends StatefulWidget {
  @override
  _TaskManagerScreenState createState() => _TaskManagerScreenState();
}

class _TaskManagerScreenState extends State<TaskManagerScreen> {
  List<Task> _tasks = [];
  final myFormat = DateFormat('yyyy年MM月dd日');

  @override
  void initState() {
    super.initState();
    // アプリ起動時に保存しているタスクを読み込む
    _loadTasks();
  }

  // タスクを保存する関数
  Future<void> _saveTasks() async {
    SharedPreferences prefs = await SharedPreferences.getInstance();
    // タスクリストをJSON形式に変換
    List<String> tasksJson = _tasks.map((task) => jsonEncode(task.toJson())).toList();
    await prefs.setStringList('tasks', tasksJson);  // リストを保存
  }

  // タスクを読み込む関数
  Future<void> _loadTasks() async {
    SharedPreferences prefs = await SharedPreferences.getInstance();
    List<String>? tasksJson = prefs.getStringList('tasks');
    if (tasksJson != null) {
      setState(() {
        _tasks = tasksJson.map((task) => Task.fromJson(jsonDecode(task))).toList();
      });
    }
  }

  // タスクを作成する関数
  Future<void> _showAddTaskDialog() async {
    // TextFieldの値を取得、変更、リセットできるコントローラー
    final _nameController = TextEditingController();
    // 締め切りの初期値として今日の日付を指定
    DateTime _selectedDeadline = DateTime.now();

    // ダイアログはasync/awaitで書く必要
    await showDialog(
      context: context,
      builder: (BuildContext context) {
        return StatefulBuilder(
          builder: (context, setState) {
            return AlertDialog(
              title: Text('タスクを追加'),
              content: Column(
                //Columnの長さを最小化する
                mainAxisSize: MainAxisSize.min,
                children: [
                  // タスク名を設定
                  TextField(
                    controller: _nameController,
                    decoration: InputDecoration(labelText: 'タスク名'),
                  ),
                  SizedBox(height: 20),
                  // 締め切り日を設定
                  ListTile(
                    title: Text('締め切り: ${myFormat.format(_selectedDeadline.toLocal())}'),
                    trailing: Icon(Icons.calendar_today),
                    onTap: () async {
                      // 締め切り日をカレンダーから選択
                      final DateTime? picked = await showDatePicker(
                        context: context,
                        // 締め切り日の初期値
                        initialDate: _selectedDeadline.toLocal(),
                        // 締切日に指定できるのは2024年〜2100年
                        firstDate: DateTime(2024),
                        lastDate: DateTime(2100),
                      );
                      if (picked != null) {
                        setState(() {
                          _selectedDeadline = picked;
                        });
                      }
                    },
                  ),
                ],
              ),
              // ダイアログの下部に出すボタンの設定
              actions: [
                // タスク作成をキャンセルするボタン
                TextButton(
                  child: Text('キャンセル'),
                  onPressed: () => Navigator.pop(context),
                ),
                // タスク作成を完了するボタン
                TextButton(
                  child: Text('追加'),
                  onPressed: () {
                    // タスク名が空でないか確認して実行
                    if (_nameController.text.isNotEmpty) {
                      _tasks.add(Task(
                        name: _nameController.text,
                        deadline: _selectedDeadline,
                      ));
                      // 作成したタスクを保存
                      _saveTasks();
                      Navigator.pop(context);
                    }
                  },
                ),
              ],
            );
          }
        );
      },
    );
    setState(() {});
  }

  // タスクを編集する関数
  Future<void> _showEditTaskDialog(int index) async {
    // 選択したn番目のタスクの名前を取得
    final _nameController = TextEditingController(text: _tasks[index].name);
    // 選択したn番目のタスクの締め切り日を取得
    DateTime _selectedDeadline = _tasks[index].deadline;

    await showDialog(
      context: context,
      builder: (BuildContext context) {
        return AlertDialog(
          title: Text('タスクを編集'),
          content: Column(
            //Columnの長さを最小化する
            mainAxisSize: MainAxisSize.min,
            children: [
              // タスク名を設定
              TextField(
                controller: _nameController,
                decoration: InputDecoration(labelText: 'タスク名'),
              ),
              SizedBox(height: 20),
              // 締め切り日を設定
              ListTile(
                title: Text('締め切り: ${_selectedDeadline.toLocal()}'),
                trailing: Icon(Icons.calendar_today),
                onTap: () async {
                  // 締め切り日をカレンダーから選択
                  final DateTime? picked = await showDatePicker(
                    context: context,
                    // 締め切り日の初期値
                    initialDate: _selectedDeadline,
                    // 締切日に指定できるのは2024年〜2100年
                    firstDate: DateTime(2024),
                    lastDate: DateTime(2100),
                  );
                  if (picked != null) {
                    setState(() {
                      _selectedDeadline = picked;
                    });
                  }
                },
              ),
            ],
          ),
          actions: [
            // タスク編集をキャンセルするボタン
            TextButton(
              child: Text('キャンセル'),
              onPressed: () => Navigator.pop(context),
            ),
            // タスク編集を完了するボタン
            TextButton(
              child: Text('保存'),
              onPressed: () {
                // タスク名が空でないか確認して実行
                if (_nameController.text.isNotEmpty) {
                  _tasks[index].name = _nameController.text;
                  _tasks[index].deadline = _selectedDeadline;
                  // タスクの変更を保存
                  _saveTasks();
                  Navigator.pop(context);
                }
              },
            ),
          ],
        );
      },
    );
    setState(() {});
  }

  // タスクを削除する関数
  void _deleteTask(int index) {
    setState(() {
      _tasks.removeAt(index);
      // タスクの削除を保存
      _saveTasks();
    });
  }

  @override
  Widget build(BuildContext context) {
    // 締め切り日が近い順にタスクをソート
    _tasks.sort((a, b) => a.deadline.compareTo(b.deadline));

    return Scaffold(
      appBar: AppBar(
        title: Text('タスク管理アプリ'),
      ),
      // タスク一覧を表示
      body: ListView.builder(
        itemCount: _tasks.length,
        itemBuilder: (context, index) {
          // n番目のタスク
          Task task = _tasks[index];
          // ListのアイテムをDismissibleでラップする事でスワイプ削除を実現できる
          return Dismissible(
            key: Key(task.name),
            background: Container(color: Colors.red, child: Icon(Icons.delete, color: Colors.white)),
            // スワイプした時に_deleteTaskを実行
            onDismissed: (direction) => _deleteTask(index),
            // Listのアイテム（タスク）の中身
            child: ListTile(
              // タスク名
              title: Text(task.name),
              // 締切日
              subtitle: Text('締め切り: ${ myFormat.format(task.deadline.toLocal())}'),
              // 完了／未完了を表すチェックボックス
              trailing: Checkbox(
                value: task.isCompleted,
                // チェックボックスが切り替わった時に以下を実行
                onChanged: (value) {
                  setState(() {
                    _tasks[index].isCompleted = !_tasks[index].isCompleted;
                    _tasks.removeAt(index);
                    // タスクの完了／未完了の変更を保存
                    _saveTasks();
                  });
                },
              ),
              // Listのアイテム（タスク）をタップするとタスクの編集可能
              onTap: () {
                _showEditTaskDialog(index);
              },
            ),
          );
        },
      ),
      // タスクを追加（作成）するボタン
      floatingActionButton: FloatingActionButton(
        onPressed: _showAddTaskDialog,
        child: Icon(Icons.add),
      ),
    );
  }
}
```

### 解説

#### 1. Taskクラス

`Task`クラスは、アプリで扱うタスクの基本情報を管理するためのクラスです。各タスクは名前、締め切り、完了ステータスを持っています。`isCompleted`プロパティを使って、タスクが完了しているかどうかを管理しています。

#### 2. タスクの追加

`_showAddTaskDialog`関数では、ダイアログを表示してタスクを追加することができます。タスク名を入力し、締め切り日をカレンダーから選択後、「追加」ボタンを押すことで、新しいタスクが`_tasks`リストに追加されます。

#### 3. タスクの編集

`_showEditTaskDialog`関数では、ダイアログを表示して既存のタスクを編集することができます。タスク名、締め切り日を編集し、変更内容を保存することができます。

#### 4. タスクの削除

`Dismissible`ウィジェットを使うことで、タスクのスワイプ削除を実装することができます。スワイプすると`_deleteTask`関数が呼び出され、タスクがリストから削除されます。

#### 5. タスクの完了／未完了の切り替え

各タスクには`Checkbox`があり、チェックを付けることでタスクの完了／未完了状態を切り替えることができます。チェックを付けると、`isCompleted`プロパティが反転します。

#### 6. タスクのソート

`build`メソッド内で、`_tasks.sort((a, b) => a.deadline.compareTo(b.deadline));` を使って、締め切り日 (`deadline`) が近い順にタスクを並べ替えています。このソートは、毎回画面を描画するたびに行われます。これにより、タスクは常に締め切りが近い順に表示されるようになります。

#### 7. 端末のストレージへのタスクの保存・読み込み
`SharedPreferences`を使って、タスクをJSON形式で端末のストレージに書き込み・読み込みします。`_saveTasks()`関数でタスクを書き込み、`_loadTasks()`関数でタスクを読み込みます。また、`Task`クラスにJSON変換用のメソッドを記載しており、`SharedPreferences`で扱える形式に変換しています。

## 4. おわりに

この記事では、Flutterを使って基本的な機能を持ったTODO / タスク管理アプリを作成しました。今回のTODO / タスク管理アプリをベースにして、通知機能や独自の機能を追加することで、より本格的なアプリを作成することも可能ですので、ぜひチャレンジしてみてください。