🐔

【Flutter】GetXの世界3 ~Todoアプリを作ってみよう前編~

2021/11/13に公開

GetXの世界3 ~Todoアプリを作ってみよう前編~

はじめに

この記事は以下の記事の続きです。
GetXの世界1 ~カウンターアプリで基本を学ぼう編~
GetXの世界2 ~ログインフォームを作ってみよう編~

Todoアプリの完成例
Todoアプリの完成例

今回はGetXSharedPreferencesの二つのライブラリのみを使用して、「データを保存可能で、テーマや言語を切り替えることができ、動的URLに対応したTodoウェブアプリ」を一から作ってみたいと思います。タスクの追加・更新・削除と基本的な機能を搭載するまでを前編、SharedPreferencesへのデータ保存、テーマやロケールの変更管理機能を搭載するまでを後編とさせていただきます。

GetX自体というよりは、GetXを使って一つのTodoアプリを完成させることに主眼を置いていますが、GetXの以下の機能の使用方法についても学ぶことができます。

  • Route名を通じてパラメーターをGetXに渡してページを描画し、動的にURLを生成する方法
  • GetMaterialAppによるロケールやテーマの管理方法
  • Translationsクラスを使った翻訳データの作成方法
  • Bindingsで依存オブジェクトを束ねてアプリ全体、あるいはRouteに注入する方法

GetX日本語ドキュメント
📕 Readme / 📚 ドキュメント

※ 誤訳訂正や表現のご提案はこちらにいただけると助かります。

【本記事で使用するライブラリ】

  • get: ^4.3.8
  • shared_preferences: ^2.0.8

Todoアプリ概要

作成に取りかかる前に、まずはウェブアプリの概要や全体像を紹介します。

実現したい機能

今回実現したい機能の一覧はこちらです。

  • タスク一覧が表示できる
  • タスクが新規追加できる
  • タスクが更新できる
  • タスクが削除できる
  • タスク一覧をフィルタできる(完了タスクを隠す)
  • 完了タスクを一括削除できる
  • 未完了タスクの数をリアルタイムで確認できる
  • タスクごとに固有のURLを付与できる(動的URL、ディープリンク)
  • アプリ全体のロケールを変更できる(日英の切り替え)
  • アプリ全体のカラーテーマを変更できる
  • 存在しないリンクにアクセスすると専用画面(404)に誘導できる
  • タスクデータを保存/ロードできる
  • フィルタの状態、ロケール、テーマの設定を保存/ロードできる

ウィジェットツリーのイメージ

ざっくりのウィジェットツリー
ざっくりのウィジェットツリー

タスクの「新規作成」と「既存更新」の画面はAddTodoPageという共通のクラスですが、それぞれでRouteを分けています。Route名の「/todo/ほにゃらら」のほにゃららの部分をタスクIDとし、それをRoute管理システムが読み取ってAddTodoPageに渡すことで描画内容を決定します。

(以降、この記事ではほにゃららの部分を「パラメーター」あるは「parameters」と記します)

この方法だとTodoインスタンスを特定する要素がHomePageに直接依存しないため、仮に「/todo/000000」というURLをブックマークして後に再訪しても同じタスク内容が表示させることができます。

Webアプリ版ディープリンクのようなイメージですね。GetXではこのパラメーターを含む名前付きRouteの指定が楽ちんなので今回取り上げることにしました。

Route管理システムを経由した情報伝達イメージ
Route管理システムを経由した情報伝播イメージ

必要なページとウィジェットのイメージ

HomePage
HomePage

AddTodoPage
AddTodoPage

作成するページとウィジェットはおおむねこのような感じです。

HomePageの左上アイコンはフィルタ用のボタン(完了タスクを隠す)、右上のアイコン2つはロケールの切り替えとテーマの切り替え用のボタンです。

真ん中上のタイトルの部分に未完了タスクの数をリアルタイムで表示します。

AddTodoPageの真ん中上にはとりあえずタスクIDを表示させます。URL経由で渡されたパラメーターと実際のタスクIDが一致しているかを見るためです(原理的に一致しないわけはないのですが、空いているのが落ち着かないので埋め草として)。

必要なControllerやModelのイメージ

コントローラー、モデル、サービスクラス
コントローラー、モデル、サービスクラス

このデモアプリではタスクや設定のデータをSharedPreferencesに保存します。SharedPreferencesのインスタンスを保持し、データの出し入れを直接担当するクラスを「StorageService」とします。

Todoアプリのベースを作る

まずはシンプルに「タスク一覧をリスト表示すること」を最初のマイルストーンにしましょう!少し考えを巡らせる必要がある設計や複雑な処理はとりあえず後回しにします。

Todoモデルのベースを作る

このアプリ唯一のモデルクラスです。

タスクを特定するための一意のID、内容(description)、完了/未完了の状態をプロパティに持つimmutableなオブジェクトです。

models > todo.dart

class Todo {
  final String id;
  final String description;
  final bool done;

  // 説明【1】
  Todo({required this.description, this.done = false})
      : id = DateTime.now().millisecondsSinceEpoch.toString();

  // 説明【2】
  const Todo.withId({required this.id, required this.description, this.done = false});

  // サンプルタスク
  static const initialTodos = [
    Todo.withId(
      id: '0',
      description: '犬の散歩',
      done: true,
    ),
    Todo.withId(
      id: '1',
      description: '学校の宿題\n- 国語\n- 算数\n- 英語',
    ),
    Todo.withId(
      id: '2',
      description: '夏休みの計画',
    ),
  ];
}

説明【1】

  • デフォルトコンストラクタ。ユニークIDとして「1970-01-01からの経過ミリ秒」を表すmillisecondsSinceEpochを使用。コンストラクタが呼び出されたタイミングが基本的にはそのままIDになります。
  • IDはmillisecondsSinceEpochの型 intのままでもいいのですが、後々動的にURLを生成する際に少し都合がいいため、Stringに変換しています。

説明【2】

  • 名前付きコンストラクタ。initialTodosのようにあらかじめサンプルTodoを複数同時に生成する場合に、デフォルトコンストラクタだとID(ミリ秒)が被ってしまうかもしれないので、任意のIDを指定できるコンストラクタも用意します。
  • これはディープリンクの動作確認のためにIDをあらかじめ把握できるものにしたい&SharedPreferencesからのデータを復元してTodoオブジェクトを作り直すときに、同じIDを使えるようにする狙いもあります。

TodoControllerのベースを作る

次に、GetxControllerを継承したTodoControllerを作成しましょう。

controllers > todo_controller.dart
class TodoController extends GetxController {
  final _todos = <Todo>[].obs;

  
  void onInit() {
    super.onInit();
    _todos.addAll(Todo.initialTodos);
  }

  List<Todo> get todos => _todos; // TODO:フィルタの状態によって返すTodoを変える
}

TodoControllerのインスタンスが立ち上がったときの処理を onInit() 内に書きます。ここでは、Todoモデルに設定したstatic変数 initialTodos の内容(サンプルタスク3つ)をRxListである_todosに追加しています。

onInit()はbuildメソッドが走る前に行われる処理なので、タスクリストが空で表示されることはありません。(onInit()を使わずRxListの [ ] 内に直接サンプルタスクを書いても構わないです)

UIの出発点であるHomePageを作る

モデルやコントローラーのベースができたので、早速UIを作っていきましょう。

pages > home_page.dart
class HomePage extends StatelessWidget {
  HomePage({Key? key}) : super(key: key);

  // TodoControllerインスタンスをシングルトンとして登録
  final todoController = Get.put(TodoController());

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        centerTitle: true,
        title: const Text('タイトル(仮)'), // TODO:未完了タスクの数を表示
        leading: IconButton(
          icon: const Icon(Icons.filter_alt_outlined), // TODO:フィルタの状態をアイコンに反映
          onPressed: () {}, // TODO:フィルタの処理を呼び出す
        ),
        actions: [
          IconButton(
            icon: const Icon(Icons.language),
            onPressed: () {}, // TODO:ロケール変更の処理を呼び出す
          ),
          IconButton(
              icon: const Icon(Icons.color_lens),
              onPressed: () {} // TODO:テーマ変更の処理を呼び出す
              ),
        ],
      ),
      body: TodoList(), // TODO:リスト表示のウィジェットを作成
    );
  }
}

まずは冒頭のHomePageのラフ図をイメージして大まかに 足場(Scaffold) を組み立てます。あとで書く予定の処理やウィジェットは「// TODO:〜」としておきましょう。

次にScaffoldの body に指定した TodoListウィジェット を作成します。

TodoListウィジェットを作る

widgets > todo_list.dart
class TodoList extends StatelessWidget {
  TodoList({Key? key}) : super(key: key);

  // HomePageビルド時にputしたControllerインスタンスを取得
  final todoController = Get.find<TodoController>();

  
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(15),
      child: Stack(
        alignment: Alignment.bottomCenter,
        children: [
          Obx(
            () {
              final todos = todoController.todos;
              return ListView.builder(
                itemCount: todos.length,
                itemBuilder: (context, index) {
                  final todo = todos[index];
                  return TodoTile(todo: todo); // TODO:TodoTileウィジェットを作成
                },
              );
            },
          ),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              // TODO:ActionButtonを作成する(左下の完了削除ボタン)
              // TODO:ActionButtonを作成する(右下の新規追加ボタン)
            ],
          ),
        ],
      ),
    );
  }
}

個々のタスクをリスト表示するため、ListView.builderコンストラクタを使用します。TodoControllerに設けたゲッター「todos」とListViewのインデックス情報を元に、TodoTileウィジェットにタスク情報を渡します。

RxList.valueを直接渡さずゲッターを挟んでいる理由は、クラス外からリストの内容を変更できないようにという意図もありますが、フィルタの状態(完了タスクを隠すか否か)によりゲッターメソッド内でリスト内容に変更を加えるためという意味合いの方が大きいです。

後々TodoControllerに機能を実装しましょう。

TodoTileウィジェットを作る

widgets > todo_tile.dart
class TodoTile extends StatelessWidget {
  final Todo todo;

  const TodoTile({required this.todo, Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return ListTile(
      onTap: () {}, // TODO:RouterにIDを渡してタスク編集画面に遷移
      leading: const Icon(
        Icons.circle,
        color: Colors.grey,
      ), // TODO:TodoCheckboxウィジェットの作成
      title: Text(
        todo.description,
        style: todo.done
            ? const TextStyle(
                color: Colors.grey,
                fontSize: 30,
                decoration: TextDecoration.lineThrough,
              )
            : const TextStyle(fontSize: 30),
      ),
      trailing: IconButton(
        onPressed: () {},
        icon: const Icon(Icons.delete),
      ),
    );
  }
}

タスク内容をビューに反映させるウィジェットとしてListTileを使用します。タイルの先頭(leading)に完了/未完了を操作するチェックボックスを、真ん中(title)にTodo.descriptionを、右端(trailing)に削除ボタンを配置します。

Todo.descriptionを表示するTextウィジェットのスタイルは、タスクが完了していれば文字色をグレーにした上で取り消し線を施します。

これでTodoアプリの基本的な骨組みは完成しました。TodoCheckboxの部分はとりあえず仮のIconウィジェットを配置して、これまでの成果を確認してみましょう。

最初のマイルストーン
最初のマイルストーン

いい感じですね!機能はまだまだ実装できていませんが、これで最初のマイルストーンは達成できました。

次のマイルストーンは「タスクの新規作成、更新ができる」ところまでとしましょう。

Todoアプリに編集画面やボタン、機能を付け足す

アプリのテーマを設定

その前に、アプリの見た目を冒頭のGIF動画のものに近づけておきます。

data > app_theme.dart
class AppTheme {
  static final light = ThemeData(
    fontFamily: 'DotGothic',
    colorScheme: const ColorScheme.light(
      primary: Colors.cyan, // AppBarやチェックボックスなどで使用
      secondary: Colors.pink, // 画面右下のボタンに使用
    ),
  );

  static final dark = ThemeData(
    fontFamily: 'DotGothic',
    colorScheme: const ColorScheme.dark(
      primary: Colors.deepPurple,
      secondary: Colors.amber,
      surface: Colors.deepPurple, // darkモードにおけるAppBarの背景色
    ),
  );
}

「lightモード」と「darkモード」それぞれのモード用に、フォント情報と色情報のデータをセットにしたThemeDataを作成します。

フォントにはGoogleフォントのドットゴシックを、色情報の管理にはColorSchemeクラスのlight/darkコンストラクタを利用します。(このコンストラクタはlight/darkそれぞれに最適な色情報がデフォルト値となり、あらかじめすべての色情報を決定する必要がないので気軽にColorShemeを扱いたい場合に便利です)

ここではThemeDataのデータを保持するクラスを一つ立てましたが、MaterialApp(GetMaterialApp)のtheme/darkThemeプロパティに直接書いてもらっても大丈夫です。

それではテーマをアプリに適用するため、GetMaterialAppを設定しましょう。

main.dart
void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return GetMaterialApp(
      debugShowCheckedModeBanner: false, // 今回右上が被るので
      theme: AppTheme.light,
      darkTheme: AppTheme.dark,
      themeMode: ThemeMode.system, // TODO:SharedPreferencesの保存データを読み込んでここに設定
      locale: null, // TODO:SharedPreferencesの保存データを読み込んでここに設定
      defaultTransition: Transition.noTransition, // Webアプリなのでトランジションなし
      initialRoute: '/home',
      getPages: [
        GetPage(
          name: '/home',
          page: () => HomePage(),
        ),
      ],
    );
  }
}

GetMaterialAppはMaterialAppをベースにGetX独自の機能を追加したラッパーウィジェットです。

そして上記で設定した defaultTransitiongetPages はGetMaterialApp特有のプロパティです。defaultTransition は名前の通り、画面遷移時のデフォルトトランジションを指定することができます。

指定はTransition enumで行います。「Transition.fade」 「Transition.rightToLeft」など多くの種類がありますが、今回はウェブアプリなのでトランジションなしの「Transition.noTransition」 にしています。

(個別にトランジションを指定する場合は、Get.to() や Get.toNamed() の transitionプロパティで同様の指定をします。)

getPages にはアプリで使用するページ(GetPage)をリストで指定します。GetPage自体はPageを継承したクラスで、指定したビューとRoute名からRoute(GetPageRoute)を作成してくれます。

ThemeMode(dark/light/system)を指定するthemeModeと、アプリのLocaleを指定するlocaleには後でSharedPreferencesから読み込んだデータを設定したいので、とりあえず仮の値としておきます。

ダークモードの例
ダークモードの例

ライトモードの例
ライトモードの例

だいぶ見た目が整ってきましたね。それでは「タスクの新規作成、更新ができる」ようビューと機能をこしらえていきます。

新規作成 兼 既存更新の画面 AddTodoPage のベースを作る

AddTodoPageの描画ロジックは少し込み入ってるかもしれません。シンプルにTodoオブジェクトを渡してそれに沿って描画するだけならこのような処理を行う必要はないですが、冒頭で申し上げた通り、今回はブラウザのURL入力欄から他のタスク編集画面に飛ぶ仕組み(ディープリンク)実装します。

そのため、「HomePageからの遷移」でも、「URL入力欄からのアクセス」でも同じ内容を描画できるように 共通の識別子(identifier) をAddTodoPageに渡してやる必要があります。また、この「共通の識別子」は後者に合わせる形で、URLの一部として表現できるもの(文字列) である必要がありそうです。

そこで利用できるのが、Todoモデルで設定した id プロパティ(今回のアプリではmillisecondsSinceEpoch)です。

AddTodoPageの描画ロジック
AddTodoPageの描画ロジック

AddTodoPageにタスクidを受け入れるプロパティを設け、idがnullなら「新規作成画面」としてビルドし、null以外なら「既存編集画面」としてビルドします。

また、URL入力欄ではユーザーが存在しないIDを入れることもできてしまうので、その場合は「HomePageに戻る」という処理にすることにしましょう。

それを表現したコードが下記です。少し長くなってしまったので折りたたみにしました。

AddTodoPageコード例
pages > add_todo_page.dart
class AddTodoPage extends StatefulWidget {
  final String? todoId;

  const AddTodoPage({Key? key, this.todoId}) : super(key: key);

  
  _AddTodoPageState createState() => _AddTodoPageState();
}

class _AddTodoPageState extends State<AddTodoPage> {
  final todoController = Get.find<TodoController>();
  final textController = TextEditingController();
  Todo? todo;

  
  void initState() {
    super.initState();
    // 既存更新の場合(新規作成は以下無視)
    if (widget.todoId != null) {
      // TODO:TodoControllerから該当タスクを探してtodoに代入
      if (todo != null) {
        // 該当タスクがあった場合TextFieldにdescription表示
        textController.text = todo!.description;
      } else {
        // TODO: 該当するタスクがない場合はHomePageへ
      }
    }
  }

  
  void dispose() {
    textController.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        // 既存編集ならID、新規作成なら「新規タスク」と表示
        title: Text('id: ${(todo?.id ?? '新規タスク')}'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(15),
        child: Stack(
          alignment: Alignment.bottomCenter,
          children: [
            Column(
              children: [
                TextField(
                  controller: textController,
                  autofocus: true,
                  decoration: const InputDecoration(
                    hintText: 'タスク入力',
                    border: InputBorder.none,
                    focusedBorder: InputBorder.none,
                  ),
                  style: const TextStyle(fontSize: 25),
                  maxLines: null, // 行数に制限なし
                ),
              ],
            ),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: const [
                // TODO:ActionButtonを作成する(左下のキャンセルボタン)
                // TODO:ActionButtonを作成する(右下の更新ボタン)
              ],
            ),
          ],
        ),
      ),
    );
  }
}

ページの動作を確認するため、GetMaterialAppのgetPagesにページを追加し、TodoTileウィジェットの ListTile > onTap から画面遷移する処理を書きます。

main.dart
// 省略
      getPages: [
        GetPage(
          name: '/home',
          page: () => HomePage(),
        ),
+       GetPage(
+         name: '/todo',
+         page: () => const AddTodoPage(),
+       ),
      ],
// 省略
widgets > todo_tile.dart
// 省略
  Widget build(BuildContext context) {
    return ListTile(
      onTap: () {
        // TODO:RouterにIDを渡してタスク編集画面に遷移
+       Get.toNamed('/todo');
      },
// 省略

AddTodoPageへの画面遷移
AddTodoPageへの画面遷移

きちんと画面遷移できました😭 遷移に伴うトランジションもGetMaterialAppのdefaultTransitionで設定した通り、ナシになっています。

タスクIDをRoute管理システムに渡す

しかし新規作成画面になってしまっているので、タスクIDをAddTodoPageに渡してやる必要がありそうです。タスクIDはTodoTileのtodoプロパティから取得できそうですが、どのようにGetXのRoute管理システムに渡してやればいいでしょうか。。?

渡し方はとても簡単で、以下のように Route名にIDの要素を追加するだけです。このRoute名は、そのままウェブアプリのURLの一部となります。

widgets > todo_tile.dart
// 省略
    return ListTile(
      onTap: () {
+       Get.toNamed('/todo/${todo.id}');
      },
// 省略

さらに、今度は情報の受け手側であるRoute管理システムに「/todo/ほにゃらら」の部分がパラメーターであり、AddTodoPageの描画に必要な要素であることを伝えなくてはなりません。

それにはまず GetPageのnameプロパティにも同様に、「'/todo/:honyarara'」とIDの要素を加える必要があります。コロン「:」の後の識別子は、パラメーターの値を取得するためのkeyとなっています。

つまり、「ほにゃらら」の値を取得するには「honyarara」というキーを使用してね とRoute管理システムに伝えることになります。

値は Get.parameters['honyarara'] で取得可能です。Get.parameters はGetMaterialAppがRoute管理システムから渡されたパラメーターを取得するためのAPIです。

main.dart GetMaterialApp > getPages
// 省略
        // 新規作成用
        GetPage(
          name: '/todo',
          page: () => const AddTodoPage(),
        ),
        // 既存更新用
+       GetPage(
+         name: '/todo/:todoId',
+         page: () => AddTodoPage(todoId: Get.parameters['todoId']),
+       ),
// 省略

ちなみに新規作成の場合と既存更新の場合ではRoute名が異なり、当然Routeも異なるため、同じAddTodoPageでも通常はこのようにGetPageを2つに分ける必要があります。

ただし、URLの形式(Route名)をクエリパラメーターの形式にした場合は GetPageを一つにまとめることができます!

ここでいうクエリパラメーターとは、「/todo?id=000000」のような形式のことを指していると考えてください。この場合のコードを実際に見てみましょう。

widgets > todo_tile.dart
// 省略
    return ListTile(
      onTap: () {
+       Get.toNamed('/todo?id=${todo.id}');
      },
// 省略
main.dart GetMaterialApp > getPages
// 省略
+       // 新規作成 兼 既存更新用
+       GetPage(
+         name: '/todo',
+         // パラメーターが渡されていなければtodoIdはnullなので新規作成画面に
+         page: () => AddTodoPage(todoId: Get.parameters['id']),
+       ),
// 省略

GetPageを新規作成と既存更新で分ける必要がない上に Get.toNamed の指定で「 : 」も省くことができるので、こちらの方がスマートですね。

ちなみにパラメーターは複数指定することも可能です。たとえば、、

// 画面遷移のボタン
    ElevatedButton(
      onTap: () {
        Get.toNamed('/laptop?brand=${laptop.brand}&os=${laptop.os}');
      },
    );
// GetMaterilaApp
        GetPage(
          name: '/laptop',
          page: () => LaptopPage(
	    brand: Get.parameters['brand'],
	    os: Get.parameters['os'],
	  ),
        ),

などとすることも。

またパラメーターの値はRoute名の中で渡すほか、Get.to や Get.toNamed のプロパティとして渡すことも可能です。以下のコードは上記のものと効果は同一で、URLにも反映されます。

// 画面遷移のボタン
    ElevatedButton(
      onTap: () {
        Get.toNamed(
+         '/laptop',
+         parameters: {'brand': laptop.brand, 'os': laptop.os},
	);
      },
    );

これでビューからRoute管理システムへ、さらにビューへ、タスクIDをパラメーターとして伝達する仕組みが整いました。あとはAddTodoPageに描画するTodoをタスクIDから取得する機能をTodoControllerに実装して動作確認しましょう。

controllers > todo_controllers.dart
class TodoController extends GetxController {
  final _todos = <Todo>[].obs;
  // 省略
+ Todo? getTodoById(String id) {
+   try {
+     return _todos.singleWhere((e) => e.id == id);
+   } on StateError {
+    return null; // 該当IDがなければnullを返す
+   }
+ }
  // 省略

RxListのsingleWhereはListのそれと同一のものです。リスト内のユニークなオブジェクトを探すメソッドで、オブジェクトが存在しないか2つ以上ある場合はStateErrorを吐きます。

その場合は前述の通りHomePageに戻る必要があるので、try-catch でエラーを拾ってnullを返します。

既存更新画面の呼び出し
既存更新画面の呼び出し

段々と形になってきました。でもまだ既存更新画面を呼び出しているだけで、実際の更新はできません。新規作成もできない状態です。機能を実装していきましょう。

Todoモデルにデータクラスの要素を加える

本格的にTodoControllerに機能実装していく前に、Todoモデルを少し見直したいと思います。

現状ではコンストラクタを作成しただけで、SharePreferencesに保存できる形式に変換したり、immutableなデータを取り扱うための機能が不足しています。

ここではVS Code拡張のDart Data Class Generatorの助けを借りて以下の3つのコード要素を追加しています。

オブジェクトのコピー
models > todo.dart
class Todo {
 // 省略
+ Todo copyWith({
+   String? id,
+   String? description,
+   bool? done,
+ }) {
+   return Todo.withId(
+     id: id ?? this.id,
+     description: description ?? this.description,
+     done: done ?? this.done,
+   );
+ }
 // 省略
}

Todoはimmutable(改変不可)のため、descriptionやdoneを更新したい場合は元のTodoオブジェクトを複製して、該当プロパティに変更を加えたオブジェクトを新たに生成する必要があります。それがこのcopyWithメソッドです。

オブジェクト ↔️ JSON文字列 変換
models > todo.dart
class Todo {
 // 省略
+ factory Todo.fromJson(String json) { // JSON文字列をTodoに
+   final mapData = jsonDecode(json);
+   return Todo.withId(
+     id: mapData['id'] as String, // ダウンキャスト
+     description: mapData['text'] as String,
+     done: mapData['done'] as bool,
+   );
+ }

+ String toJson() { // TodoをJSON文字列に
+   return jsonEncode({
+     'id': id,
+     'text': description,
+     'done': done,
+   });
+ }
 // 省略
}

SharedPreferencesのsetStringListgetStringListを使ってタスクの読み込み/保存を行うためのコンストラクタ/メソッドです。

等価性override
models > todo.dart
class Todo {
 // 省略
+ 
+ bool operator ==(Object other) {
+   if (identical(this, other)) return true;
+   return other is Todo && other.id == id; // IDが同じなら等価
+ }

+ 
+ int get hashCode =>
+     Object.hash(id.hashCode, description.hashCode, done.hashCode);
 // 省略
}

通常ならすべてのプロパティ(idとdescriptionとdone)が同じなら「等価」とするところですが、本デモアプリではidのみを等価の判断材料にしています。

TodoControllerにおいて、指定IDのタスクをリストから探すときのコードを少しだけ短く、分かりやすくするためです。

TodoControllerに様々な機能を実装する

それではTodoControllerに機能を実装していきましょう。まずは新規作成機能から。

controllers > todo_controllers.dart
class TodoController extends GetxController {
  final _todos = <Todo>[].obs;
  // 省略
+ void addTodo(String description) {
+   final todo = Todo(description: description);
+   _todos.add(todo);
+ }
  // 省略
}

次に、タスクの更新機能です。

controllers > todo_controllers.dart
class TodoController extends GetxController {
  // 省略
  // Todoのテキスト更新
+ void updateText(String description, Todo todo) {
+   final index = _todos.indexOf(todo);
+   final newTodo = todo.copyWith(description: description);
+   _todos.setAll(index, [newTodo]);
+ }
  // Todoの完了状況更新
+ void updateDone(bool done, Todo todo) {
+   final index = _todos.indexOf(todo);
+   final newTodo = todo.copyWith(done: done);
+   _todos.setAll(index, [newTodo]);
+ }
  // 省略
}

ここで直前にTodoモデルに追加した要素が活かされます。

まず等価性をoverrideして「idが同じであれば同じTodoオブジェクトとみなす」としたことで、 indexOf メソッド で指定したTodoが「_todosリスト内の何番目にあるか」の情報を取得できるようになりました。

(いずれにしてもindexWhereでIDを検索すれば済む話ではありますが、、)

そして、オブジェクトを複製するための copyWith メソッド があることで、「変更前のTodoオブジェクト」と「変更後の内容」から「変更後のTodoオブジェクト」を生成しやすくなりました。

_todos リストの更新は、setAll メソッド を利用して既存のTodoと置き換えます。

最後に削除機能を追加しましょう。

controllers > todo_controllers.dart
class TodoController extends GetxController {
  // 省略
  // 指定タスクを削除
+ void remove(Todo todo) {
+   _todos.remove(todo); // 等価性overrideしたのでOK
+ }

  // 完了タスクを一括削除
+ void deleteDone() {
+   _todos.removeWhere((e) => e.done == true);
+ }
  // 省略
}

それでは、これらの機能をビュー側で利用するためのウィジェットを作っていきます。

ActionButton ウィジェットを作る

widgets > action_button.dart
class ActionButton extends StatelessWidget {
  final String label;
  final IconData icon;
  final Color color;
  final VoidCallback? onPressed;

  const ActionButton({
    Key? key,
    required this.label,
    required this.icon,
    required this.color,
    this.onPressed,
  }) : super(key: key);

  
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: onPressed,
      style: ElevatedButton.styleFrom(
        primary: color,
        fixedSize: const Size(140, 50),
      ),
      child: Row(
        children: [
          Icon(icon),
          const SizedBox(width: 6),
          Text(label),
        ],
      ),
    );
  }
}

HomePageやAddTodoPage画面の右下、左下に表示する汎用のボタンです。

TodoCheckbox ウィジェットを作る

widgets > todo_checkbox.dart
class TodoCheckbox extends StatelessWidget {
  final Todo todo;

  const TodoCheckbox(this.todo, {Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Transform.scale(
      // チェックボックスが小さい対策
      scale: 1.5,
      child: Checkbox(
        shape: const CircleBorder(),
        checkColor: Colors.transparent,
        activeColor: Colors.grey,
        side: BorderSide(
          width: 3,
          color: Theme.of(context).colorScheme.primary,
        ),
        value: todo.done,
        onChanged: (value) {
	  // チェックボックスが押されたらTodoControllerを探してTodoを差し替え
          Get.find<TodoController>().updateDone(value!, todo);
        },
      ),
    );
  }
}

TodoTileの ListTile > leading に配置するチェックボックスです。

Checkboxクラスを活かし、チェック済みの色(activeColor)をグレー、✔︎マーク自体の色(checkColor)を透明、チェックボックス枠の色(BorderSide.color)を AppThemeクラスで設定したColorSchemeのprimary色に指定。

チェックボックスのON/OFFの状態が変化すると、先ほど機能を実装したTodoControllerの updateDoneメソッド が呼び出されてタスクリストが更新されます。

このリストはRxListなので、更新されればリストをwatchしているウィジェットも更新されます。本デモアプリの例で言えば、TodoListウィジェットの中で Obx() で囲った部分、すなわち ListView となります。

TodoControllerの機能をビュー側に埋め込む

ビュー側の部品が大方揃ったので、TodoControllerに実装した機能を埋め込んでいきましょう。

長いので必要に応じて下記アコーディオンを参照してください。変更箇所は緑の行です。

ActionButtonやTodoTileに機能を埋め込む
widgets > todo_list.dart
class TodoList extends StatelessWidget {
  TodoList({Key? key}) : super(key: key);
  
  final todoController = Get.find<TodoController>();

  
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(15),
      child: Stack(
      // 省略
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
+             ActionButton(
+               label: '完了削除',
+               icon: Icons.delete,
+               color: Colors.grey,
+               onPressed: () {
+                 // TODO: フィルタが解除されていて、一つでも完了タスクがある場合のみ動作させる
+                 todoController.deleteDone();
+               },
+             ),
+             ActionButton(
+               label: '新規作成',
+               icon: Icons.add,
+               color: Theme.of(context).colorScheme.secondary,
+               onPressed: () => Get.toNamed('/todo'),
+             ),
+           ],
+         ),
        ],
      ),
    );
  }
}
widgets > add_todo_page.dart
 // 省略
class _AddTodoPageState extends State<AddTodoPage> {
 // 省略
  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('id: ${(todo?.id ?? '新規タスク')}'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(15),
        child: Stack(
          alignment: Alignment.bottomCenter,
          children: [
	    // 省略
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
+               ActionButton(
+                 label: 'キャンセル',
+                 icon: Icons.cancel,
+                 color: Colors.grey,
+                 onPressed: () {
+                   // TODO:ブラウザから直接アクセスした場合に対応
+                   Get.back();
+                 },
+               ),
+               ActionButton(
+                 label: todo == null ? '追加' : '更新',
+                 icon: Icons.check,
+                 color: Theme.of(context).colorScheme.secondary,
+                 onPressed: () {
+                   final text = textController.text;
+                   if (todo == null && text.isNotEmpty) {
+                     todoController.addTodo(text); // 新規追加
+                   } else if (todo != null) {
+                     todoController.updateText(text, todo!); // 既存更新
+                   }
+                   // TODO:ブラウザから直接アクセスした場合に対応
+                   Get.back();
+                   }
+                 },
+               ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

新規追加(todoがnull)なら TodoController.addTodo を、既存更新(該当todoあり)なら TodoController.updateText を右下ボタンを押したときに実行します。

widgets > todo_tile.dart
class TodoTile extends StatelessWidget {
  final Todo todo;

  const TodoTile({required this.todo, Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return ListTile(
      onTap: () {
        Get.toNamed('/todo/${todo.id}');
      },
+     leading: TodoCheckbox(todo),
      // 省略
      trailing: IconButton(
        onPressed: () {
+         Get.find<TodoController>().remove(todo);
        },
        icon: const Icon(Icons.delete),
      ),
    );
  }
}

2つ目のマイルストーン
2つ目のマイルストーン

無事2つ目のマイルストーンを達成することができました🎉

まとめ

これまでのコードのまとめです。

コード例

pubspec.yaml

pubspec.yaml
name: getx_todo_base
description: A new Flutter project.
publish_to: 'none'
version: 1.0.0+1
environment:
  sdk: ">=2.12.0 <3.0.0"
dependencies:
  flutter_web_plugins:
    sdk: flutter
  flutter:
    sdk: flutter
  get: ^4.3.8
  shared_preferences: ^2.0.8
dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^1.0.0
flutter:
  uses-material-design: true
  fonts:
    - family: DotGothic
      fonts:
        - asset: fonts/DotGothic16-Regular.ttf

ビュー以外

models > todo.dart

class Todo {
  final String id;
  final String description;
  final bool done;

  Todo({required this.description, this.done = false})
      : id = DateTime.now().millisecondsSinceEpoch.toString();

  const Todo.withId(
      {required this.id, required this.description, this.done = false});

  // サンプルタスク
  static const initialTodos = [
    Todo.withId(
      id: '0',
      description: '犬の散歩',
      done: true,
    ),
    Todo.withId(
      id: '1',
      description: '学校の宿題\n- 国語\n- 算数\n- 英語',
    ),
    Todo.withId(
      id: '2',
      description: '夏休みの計画',
    ),
  ];

  Todo copyWith({
    String? id,
    String? description,
    bool? done,
  }) {
    return Todo.withId(
      id: id ?? this.id,
      description: description ?? this.description,
      done: done ?? this.done,
    );
  }

  String toJson() {
    return jsonEncode({
      'id': id,
      'text': description,
      'done': done,
    });
  }

  
  bool operator ==(Object other) {
    if (identical(this, other)) return true;
    return other is Todo && other.id == id;
  }

  
  int get hashCode =>
      Object.hash(id.hashCode, description.hashCode, done.hashCode);
}
controllers > todo_controller.dart
class TodoController extends GetxController {
  final _todos = <Todo>[].obs;

  
  void onInit() {
    super.onInit();
    _todos.addAll(Todo.initialTodos);
  }

  List<Todo> get todos => _todos; // TODO:フィルタの状態によって返すTodoを変える

  // IDからTodoを取得
  Todo? getTodoById(String id) {
    try {
      return _todos.singleWhere((e) => e.id == id);
    } on StateError {
      return null; // 該当IDがなければnullを返す
    }
  }

  // Todo新規作成
  void addTodo(String description) {
    final todo = Todo(description: description);
    _todos.add(todo);
  }

  // Todoのテキスト更新
  void updateText(String description, Todo todo) {
    final index = _todos.indexOf(todo);
    final newTodo = todo.copyWith(description: description);
    _todos.setAll(index, [newTodo]);
  }

  // Todoの完了状況更新
  void updateDone(bool done, Todo todo) {
    final index = _todos.indexOf(todo);
    final newTodo = todo.copyWith(done: done);
    _todos.setAll(index, [newTodo]);
  }

  // 指定タスクを削除
  void remove(Todo todo) {
    _todos.remove(todo); // 等価性overrideしたのでOK
  }

  // 完了タスクを一括削除
  void deleteDone() {
    _todos.removeWhere((e) => e.done == true);
  }
}
data > app_theme.dart
class AppTheme {
  static final light = ThemeData(
    fontFamily: 'DotGothic',
    colorScheme: const ColorScheme.light(
      primary: Colors.cyan, // AppBarやチェックボックスなどで使用
      secondary: Colors.pink, // 画面右下のボタンに使用
    ),
  );

  static final dark = ThemeData(
    fontFamily: 'DotGothic',
    colorScheme: const ColorScheme.dark(
      primary: Colors.deepPurple,
      secondary: Colors.amber,
      surface: Colors.deepPurple, // darkモードにおけるAppBarの背景色
    ),
  );
}

ビュー

main.dart
void main() {
  // TODO: setUrlStrategy(PathUrlStrategy());
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return GetMaterialApp(
      debugShowCheckedModeBanner: false, // 今回右上が被るので
      theme: AppTheme.light,
      darkTheme: AppTheme.dark,
      themeMode: ThemeMode.system, // TODO:SharedPreferencesの保存データを読み込んでここに設定
      locale: null, // TODO:SharedPreferencesの保存データを読み込んでここに設定
      defaultTransition: Transition.noTransition, // Webアプリなのでトランジションなし
      initialRoute: '/home',
      getPages: [
        GetPage(
          name: '/home',
          page: () => HomePage(),
        ),
        // 新規作成用
        GetPage(
          name: '/todo',
          page: () => const AddTodoPage(),
        ),
        // 既存更新用
        GetPage(
          name: '/todo/:todoId',
          page: () => AddTodoPage(todoId: Get.parameters['todoId']),
        ),
      ],
    );
  }
}
pages > home_page.dart
class HomePage extends StatelessWidget {
  HomePage({Key? key}) : super(key: key);

  // TodoControllerインスタンスをシングルトンとして現在のRouteに登録
  final todoController = Get.put(TodoController());

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        centerTitle: true,
        title: const Text('タイトル(仮)'), // TODO:未完了タスクの数を表示
        leading: IconButton(
          icon: const Icon(Icons.filter_alt_outlined), // TODO:フィルタ有無の状態をアイコンに反映
          onPressed: () {}, // TODO:フィルタの処理を呼び出す
        ),
        actions: [
          IconButton(
            icon: const Icon(Icons.language),
            onPressed: () {}, // TODO:ロケール変更の処理を呼び出す
          ),
          IconButton(
              icon: const Icon(Icons.color_lens),
              onPressed: () {} // TODO:テーマ変更の処理を呼び出す
              ),
        ],
      ),
      body: TodoList(),
    );
  }
}
pages > add_todo_page.dart
class AddTodoPage extends StatefulWidget {
  final String? todoId;

  const AddTodoPage({Key? key, this.todoId}) : super(key: key);

  
  _AddTodoPageState createState() => _AddTodoPageState();
}

class _AddTodoPageState extends State<AddTodoPage> {
  final todoController = Get.find<TodoController>();
  final textController = TextEditingController();
  Todo? todo;

  
  void initState() {
    super.initState();
    // 既存更新の場合(新規作成は以下無視)
    if (widget.todoId != null) {
      // 該当タスクを探してtodoに代入
      todo = todoController.getTodoById(widget.todoId!);
      if (todo != null) {
        // TextFieldにdescription表示
        textController.text = todo!.description;
      } else {
        // TODO: 該当するタスクがない場合はHomePageへ
      }
    }
  }

  
  void dispose() {
    textController.dispose();
    super.dispose();
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        // 既存編集ならID、新規作成なら「新規タスク」と表示
        title: Text('id: ${(todo?.id ?? '新規タスク')}'),
      ),
      body: Padding(
        padding: const EdgeInsets.all(15),
        child: Stack(
          alignment: Alignment.bottomCenter,
          children: [
            Column(
              children: [
                TextField(
                  controller: textController,
                  autofocus: true,
                  decoration: const InputDecoration(
                    hintText: 'タスク入力',
                    border: InputBorder.none,
                    focusedBorder: InputBorder.none,
                  ),
                  style: const TextStyle(fontSize: 25),
                  maxLines: null, // 行数に制限なし
                ),
              ],
            ),
            Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                ActionButton(
                  label: 'キャンセル',
                  icon: Icons.cancel,
                  color: Colors.grey,
                  onPressed: () {
                    // TODO:ブラウザから直接アクセスした場合に対応
                    Get.back();
                  },
                ),
                ActionButton(
                  label: todo == null ? '追加' : '更新',
                  icon: Icons.check,
                  color: Theme.of(context).colorScheme.secondary,
                  onPressed: () {
                    final text = textController.text;
                    if (todo == null && text.isNotEmpty) {
                      todoController.addTodo(text); // 新規追加
                    } else if (todo != null) {
                      todoController.updateText(text, todo!); // 既存更新
                    }
                    // TODO:ブラウザから直接アクセスした場合に対応
                    Get.back();
                  },
                ),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

ビュー(ウィジェット)

widgets > todo_list.dart
class TodoList extends StatelessWidget {
  TodoList({Key? key}) : super(key: key);

  // HomePageでputしたControllerインスタンスを探す
  final todoController = Get.find<TodoController>();

  
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(15),
      child: Stack(
        alignment: Alignment.bottomCenter,
        children: [
          Obx(
            () {
              final todos = todoController.todos;
              return ListView.builder(
                itemCount: todos.length,
                itemBuilder: (context, index) {
                  final todo = todos[index];
                  return TodoTile(todo: todo);
                },
              );
            },
          ),
          Row(
            mainAxisAlignment: MainAxisAlignment.spaceBetween,
            children: [
              ActionButton(
                label: '完了削除',
                icon: Icons.delete,
                color: Colors.grey,
                onPressed: () {
                  // TODO: フィルタが解除されていて、一つでも完了タスクがある場合のみ動作させる
                  todoController.deleteDone();
                },
              ),
              ActionButton(
                label: '新規作成',
                icon: Icons.add,
                color: Theme.of(context).colorScheme.secondary,
                onPressed: () => Get.toNamed('/todo'),
              ),
            ],
          ),
        ],
      ),
    );
  }
}
widgets > todo_tile.dart
class TodoTile extends StatelessWidget {
  final Todo todo;

  const TodoTile({required this.todo, Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return ListTile(
      onTap: () {
        Get.toNamed('/todo/${todo.id}');
      },
      leading: TodoCheckbox(todo),
      title: Text(
        todo.description,
        style: todo.done
            ? const TextStyle(
                color: Colors.grey,
                fontSize: 30,
                decoration: TextDecoration.lineThrough,
              )
            : const TextStyle(fontSize: 30),
      ),
      trailing: IconButton(
        onPressed: () {
          Get.find<TodoController>().remove(todo);
        },
        icon: const Icon(Icons.delete),
      ),
    );
  }
}
widgets > todo_checkbox.dart
class TodoCheckbox extends StatelessWidget {
  final Todo todo;

  const TodoCheckbox(this.todo, {Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return Transform.scale(
      // チェックボックスが小さい対策
      scale: 1.5,
      child: Checkbox(
        shape: const CircleBorder(),
        checkColor: Colors.transparent,
        activeColor: Colors.grey,
        side: BorderSide(
          width: 3,
          color: Theme.of(context).colorScheme.primary,
        ),
        value: todo.done,
        onChanged: (value) {
          // チェックボックスが押されたらTodoControllerを探してTodoを差し替え
          Get.find<TodoController>().updateDone(value!, todo);
        },
      ),
    );
  }
}
widgets > action_button.dart
class ActionButton extends StatelessWidget {
  final String label;
  final IconData icon;
  final Color color;
  final VoidCallback? onPressed;

  const ActionButton({
    Key? key,
    required this.label,
    required this.icon,
    required this.color,
    this.onPressed,
  }) : super(key: key);

  
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: onPressed,
      style: ElevatedButton.styleFrom(
        primary: color,
        fixedSize: const Size(140, 50),
      ),
      child: Row(
        children: [
          Icon(icon),
          const SizedBox(width: 6),
          Text(label),
        ],
      ),
    );
  }
}

最後に

次回は本記事の後編として、「TODO:」としてコメントした点や、WorkerとSharedPreferencesを使ったデータの読み書き、テーマ・ロケールの変更管理の機能を実装していきます。

またBindingsについても触れたいと思います。

Discussion