52
84

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

習慣改善アプリを作りながら学ぶFlutter入門

Last updated at Posted at 2021-03-30

この記事は、シンプルなアプリを作りながら、Flutter開発の全体像を大枠で学ぶ内容です。
記事の流れは、ステップを踏みながら開発できるように順番に記載した、ハンズオン形式となります。

作るもの

if-then planningのルールをスマホで管理できるものです。

if-then planningについての詳しい説明は省きますが、

  • 「もし朝起きたら、コップ一杯の水を飲む」
  • 「もしコンビニの近くに来たら、一旦過ぎてから本当に行きたいか考える」

のような、とある行動をトリガーとして良い行動を行うように、人間をプログラムするものです。

アプリ化の経緯は、今までスプレッドシートなどを使っていましたが、すぐに記録・閲覧できると便利だなと思ったためです。

機能

アプリの機能は下記のとおりです。
最低限アプリとして必要な機能だけを作成してゆきます。

  • ルールを追加することができる
  • ルールを一覧で見ることができる
  • ルールを削除することができる

開発環境

  • vscode(エディタ)
  • Flutter 2.0.1 stable
  • Dart SDK version: 2.12.0 (stable)
  • Flutterプロジェクト名:if_then_card
  • 利用するFlutterパッケージ:riverpod 0.13.1

動作確認にはFlutter2.0で正式になったweb(chrome)を利用しています。
動作が軽く、今回のアプリには実機に依存した機能はないためです。

UIの構築

挙動やデータは置いていて、ひとまず全体のUIを順々に作成していきます。

事前準備:デフォルトのカウンターアプリを消す

新規プロジェクトを作成した時点では、サンプルのカウンターアプリが表示されているので削除し、下記の状態にします。

lib/main.dart
import 'package:flutter/material.dart';

// アプリ起動
void main() {
  runApp(MyApp());
}

アプリが起動するとこのmain()が呼ばれ、runApp()が実行されます。
MyAppクラスも削除しましたので現状ではエラーになります。

トップ画面の作成

まずは、メインページとなるルールの一覧ページから作成してゆきます。

はじめに、アプリの設定とトップ画面のレイアウトを用意します。

lib/main.dart
import 'package:flutter/material.dart';

// アプリ起動
void main() {
  runApp(MyApp());
}

// アプリの設定
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false, // シミュレーターの右上にdebugというラベルが表示されなくなります
      title: 'IF THEN CARD', // アプリ名
      home: TopPage(), // 起動時に表示される画面
    );
  }
}

// トップ画面(ルール一覧)
class TopPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('CARD LIST'), // ページ名
        actions: [
          Tooltip(message: 'アイコンボタンをホバーしたときに表示されるテキスト'),
          IconButton( // 右上の+ボタン
            icon: Icon(Icons.add),
            onPressed: () => print('ルール追加画面に遷移するよ'),
          )
        ],
      ),
      body: Center(child: Text('ルールを一覧表示するよ')), // ルール一覧を表示する部分
      floatingActionButton: FloatingActionButton( // 右下の+ボタン
        onPressed: () => print('ルール追加画面に遷移するよ'),
        child: const Icon(Icons.add),
      ),
    );
  }
}

image.png

MyAppでは、MaterialAppでアプリのタイトルと、アプリ起動時に表示する最初の画面としてTopPageを設定しました。

TopPageでは、Scaffoldをまず定義しています。これにより、アプリの基本的なレイアウト部分を簡単に設定することができます。

今回は、下記の構成を作成しています。

画面位置 Scaffoldの設定 内容
上部 appBar AppBarで、画面のタイトル、+ボタン配置
本体 body ルール一覧を表示する場所
右下 floatingActionButton floatingActionButton+ボタン(フローティングボタン)

AppBarと、フローティングボタンの+ボタンには、まだ画面がないのでとりあえず押されたことがわかるようにしておきます。

print()を使うことで、ボタンを押したときにvscodeのDEBUG CONSOLEにテキストを出力することができます。

image.png

画面ごとにファイルを分割

見通しをよくするために、画面のウィジェット単位でファイル分割していきます。

libディレクトリ以下に、pageディレクトリを新規作成します。
lib/page以下に、top_page.dartファイルを作成します。

image.png

TopPageのウィジェットをtop_page.dartに移動させましょう。

lib/page/top_page.dart
import 'package:flutter/material.dart';

// トップ画面(ルール一覧)
class TopPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('CARD LIST'),
        actions: [
          Tooltip(message: 'アイコンボタンをホバーしたときに表示されるテキスト'),
          IconButton(
            icon: Icon(Icons.add),
            onPressed: () => print('ルール追加画面に遷移するよ'),
          )
        ],
      ),
      body: Center(child: Text('ルールを一覧表示するよ')),
      floatingActionButton: FloatingActionButton(
        onPressed: () => print('ルール追加画面に遷移するよ'),
        child: const Icon(Icons.add),
      ),
    );
  }
}

このときに、flutter/materialのpackageや、MyApp側からTopPageを読み込めなくなるので、vscodeのQuickFixで、importを追加し読み込ませていきます。

quick_fix.gif

ここまでの作業内容

ブランチ:1_top_page_base
https://github.com/s4shiki/if_then_card/compare/0_init...1_top_page_base

ルール一覧の作成

画面にルール一覧を表示するリストを追加していきます。

top_page.dartファイルに、新しいStatelessWidgetを継承したRuleListWidgetを用意します。

StateLessWigetを新規作成するときは、"stl"とタイプするとテンプレートがでてくるので便利です。

image.png

lib/page/top_page.dart
//...省略....
class TopPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
//...省略...
      // body: Center(child: Text('ルールを一覧表示するよ')),
      body: RuleListWidget(),
//...省略...
    );
  }
}

// ルール一覧
class RuleListWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final List<String> rules = [
      'ビールが欲しくなったら => 炭酸水を飲む',
      '食べたくなったら => ナッツを食べる',
      'タバコが吸いたくなったら => ニコレスを吸う'
    ];

    return ListView.builder(
      itemCount: rules.length, // リスト数を与える
      itemBuilder: (context, index) {
        // 表示内容を返す。itemCount分だけ繰り返される。indexは0からカウントアップする
        return Text(rules[index]);
      },
    );
  }
}

RuleListWidgetでは、ListViewのListView.builderを利用することで動的にリストを生成できるようにします。

TopPageの、ScaffoldのbodyをRuleListWidgetに向けます。

一旦、配列のテキストを表示するようにできました。

image.png

ここまでの作業内容

ブランチ:2_rule_list_base
https://github.com/s4shiki/if_then_card/compare/1_top_page_base...2_rule_list_base

ルールカードの作成

一覧のルールの表示をリッチにしていきましょう。

top_page.dartファイルに、RuleCardWidgetを作成します。

lib/page/top_page.dart
//...省略...
class RuleListWidget extends StatelessWidget {
//...省略...
    return ListView.builder(
        itemCount: rules.length,
        itemBuilder: (context, index) {
          // return Text(rules[index]); 削除する
          return RuleWidget(); // 追加
        });
  // 省略...
}

// ルールカード
class RuleWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Card(
      child: InkWell(
        onLongPress: () => {print('長押しで削除するよ')},
        child: Column(
          children: [
            ListTile(
              title: Text(
                'ビールが欲しくなったら',
                style: TextStyle(fontSize: 20, color: Colors.white),
              ),
              tileColor: Colors.blueAccent,
            ),
            ListTile(
              leading: Icon(Icons.subdirectory_arrow_right),
              title: Text('炭酸水を飲む'),
            ),
          ],
        ),
      ),
    );
  }
}

RuleListWidgetからreturnするウィジェットをTextからRuleWidgetに変更すると下記の画面になります。

image.png

ListView.builderが、rulesリストの値の数だけ実行されているので、3つ同じ情報が表示されました。

RuleWidgetの構成

はじめに、Cardで囲っています。これにより、外枠やリストで並べたときの間隔が適度に空きます。

カードを長押しした場合にカードを削除できるようにします。
Cardには、長押しの設定が存在していません。
なので、onLongPressを持つInkWellでCardの中全体を覆うことによってイベントを付与しておきます。

InkWell.gif

Cardの上半分に、条件(ビールが欲しくなったら)、下半分に行動(炭酸水を飲む)という構成にしたいので、CardのレイアウトをColumnで分割し、ListTileを上下に2つ並べています。

ListTileは、ListViewで並べたときに表示するのによく使う情報を設定することができます。

(余談) ListTile1つで表示する

ListTileを2つ並べなくても、1つのListTileのsubtitleに行動を出力するようにすれば、Columnも不要になります。
今回は見栄えを変えたかったのでListTile2つで構成しました。

lib/page/top_page.dart
//...省略...
// ルールカード
class RuleWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Card(
      child: InkWell(
        onLongPress: () => {print('長押しで削除するよ')},
        child: ListTile(
          title: Text(
            'ビールが欲しくなったら',
            style: TextStyle(color: Colors.white),
          ),
          subtitle: Text('炭酸水を飲む'),
          tileColor: Colors.blueAccent,
        ),
      ),
    );
  }
}

image.png

ここまでの作業内容

ブランチ:3_rule_card
https://github.com/s4shiki/if_then_card/compare/2_rule_list_base...3_rule_card

RuleWidgetに情報を渡してカードを再利用する

カードに表示されている内容が固定なので、rulesリストの値を表示するようにしていきましょう。

そのためには、RuleListWidgetから、RuleWidgetに情報を渡すように修正していきます。
rulesリストの構成も変更しているので注意してください。

lib/page/top_page.dart
// 省略...
class RuleListWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
   // カードに表示するルールリスト
    final List<Map<String, String>> rules = [
      {'situation': 'ビールが欲しくなったら', 'action': '炭酸水を飲む'},
      {'situation': '食べたくなったら', 'action': 'ナッツを食べる'},
      {'situation': 'タバコが吸いたくなったら', 'action': '二コレスを吸う'},
    ];
    // カード上部の背景色リスト
    final List<Color> colors = [
      Colors.blueAccent,
      Colors.orangeAccent,
      Colors.greenAccent,
      Colors.redAccent
    ];

    return ListView.builder(
      itemCount: rules.length,
      itemBuilder: (context, index) {
        // リスト生成
        return RuleWidget(
          situation: rules[index]['situation'],
          action: rules[index]['action'],
          conditionColor: colors[index % colors.length],
        );
      },
    );
  }
}

// ルールカード
class RuleWidget extends StatelessWidget {
  final String situation;
  final String action;
  final Color conditionColor;

  RuleWidget({this.situation, this.action, this.conditionColor});

  @override
  Widget build(BuildContext context) {
    return Card(
      child: InkWell(
        onLongPress: () => {print('長押しで削除するよ')},
        child: Column(
          children: [
            ListTile(
              title: Text(
                situation,
                style: TextStyle(fontSize: 20, color: Colors.white),
              ),
              tileColor: conditionColor,
            ),
            ListTile(
              leading: Icon(Icons.subdirectory_arrow_right),
              title: Text(action),
            ),
          ],
        ),
      ),
    );
  }
}

image.png

RuleListWidgetから見ていきましょう。

rulesリストは、カード側で条件と行動を分けて表示したいため、テキストをMapでkey-valueのコレクション配列にしました。

colorsリストは、カードの条件の背景色を青以外にも変更するために用意しました。

これらの値を、ListViewのindexで順番に取り出し、RuleWidgetの初期値としてこれらを渡すようにています。

ListView.builderのreturn
return RuleWidget(
  situation: rules[index]['situation'],
  action: rules[index]['action'],
  conditionColor: colors[index % colors.length], // 色を配列からループして取り出せるようにしている
);

RuleWidgetでは、渡ってくる初期値を受け取れるようにします。

RuleWidget
class RuleWidget extends StatelessWidget {
  final String situation;
  final String action;
  final Color conditionColor;

  RuleWidget({this.situation, this.action, this.conditionColor});
//省略...

finalを変数に指定し、コンストラクタでの初期値設定後は値を変更できないようにしています。

コンストラクタは、引数をブラケット({})で囲むことによって、名前付きパラメーターを受け取ることができます。パラメータのキー名と同名のインスタンス変数を記述すると、インスタンス変数への代入も省略することができます。

ListTileでべた書きしていたテキストと色を、それぞれの変数で置き換えています。

RuleWidgetのListTile
// ルールカード
class RuleWidget extends StatelessWidget
//...省略...
            ListTile(
              title: Text(
                // 'ビールが欲しくなったら' 削除
                situation, // 変数に置き換え
                style: TextStyle(fontSize: 20, color: Colors.white),
              ),
              // tileColor: Colors.blueAccent, 削除
              tileColor: conditionColor, // 変数に置き換え
            ),
            ListTile(
              leading: Icon(Icons.subdirectory_arrow_right),
              // tile: Text('炭酸水を飲む') 削除
              title: Text(action), // 変数に置き換え
            ),
//...省略...

ここまでの作業内容

ブランチ:4_rule_card_data
https://github.com/s4shiki/if_then_card/compare/3_rule_card...4_rule_card_data

ルール追加ページの作成

次は、ルール追加ページを作成していきます。

pageディレクトリ以下にadd_page.dartファイルを新規作成します。

  • /lib/page/add_page.dart

image.png

TopPageのときと同様に、まずは、Scaffoldで画面のレイアウトを置きます。

lib/page/add_page.dart
import 'package:flutter/material.dart';

// ルール追加画面
class AddPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Add Rule'),
      ),
      body: Center(child: Text('ルール追加フォームを表示するよ')),
    );
  }
}

開発しやすいように、アプリ起動時の画面を一旦AddPageに変更します。

lib/main.dart
import 'package:flutter/material.dart';
import 'package:if_then_card/page/top_page.dart';
import 'package:if_then_card/page/add_page.dart'; // 追加

//...省略...
class MyApp extends StatelessWidget {
//...省略...
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'IF THEN CARD',
//      home: TopPage(), // 起動時に表示される画面
      home: AddPage(), // 起動時に表示される画面
    );
//...省略...
}

うまくいけば、ルール追加画面が表示できたはずです。

image.png

それでは、追加フォームを作成していきます。
RuleFormをStatefulWidgetで作成します。

StatefulWidgetは、"stf"とタイプするとクラスのテンプレートが呼び出せます。

image.png

lib/page/add_page.dart
import 'package:flutter/material.dart';

// ルール追加画面
class AddPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('ADD RULE'),
      ),
      // body: Center(child: Text('ルール追加フォームを表示するよ')), 削除
      body: Padding(
        // フォームの周りに少し余白をもたせたいのでpaddingを追加
        padding: const EdgeInsets.all(8.0),
        child: RuleForm(),
      ),
    );
  }
}

// ルールフォーム
class RuleForm extends StatefulWidget {
  @override
  _RuleFormState createState() => _RuleFormState();
}

class _RuleFormState extends State<RuleForm> {
  final _formKey = GlobalKey<FormState>();

  // Submit時にフォームに値が入力されているか確認するメソッド
  String inputValid(value) {
    if (value == null || value.isEmpty) {
      return '入力してください';
    }
    return null;
  }

  @override
  Widget build(BuildContext context) {
    String situationText; // 条件の入力値
    String actionText; // 行動の入力値

    return Form(
      key: _formKey,
      child: Column(
        children: [
          TextFormField(
            decoration: InputDecoration(
              labelText: "条件",
              hintText: "朝起きたら",
            ),
            autofocus: true,
            textAlign: TextAlign.center,
            onChanged: (text) {
              print("条件の入力値:$text");
              situationText = text;
            },
            validator: (value) => inputValid(value),
          ),
          TextFormField(
            decoration: InputDecoration(
              labelText: "行動",
              hintText: "水を飲む",
            ),
            textAlign: TextAlign.center,
            onChanged: (text) {
              print("行動の入力値:$text");
              actionText = text;
            },
            validator: (value) => inputValid(value),
          ),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: ElevatedButton(
              onPressed: () {
                if (_formKey.currentState.validate()) {
                  ScaffoldMessenger.of(context)
                      .showSnackBar(SnackBar(content: Text('登録しました')));
                  print("登録したよ。条件:$situationText 行動:$actionText");
                }
              },
              child: Text("Submit"),
            ),
          )
        ],
      ),
    );
  }
}

フォーム.gif

主には、cookbookのバリデーション付きフォームを参考にしています。

フォームは、フォーカスやSubmit時のバリデーションエラーなどで動的に動きます。
そのため静的なウィジェットを配置するStatelessWidgetではなく、動的に状態を変更可能なStatefulWidgetを利用します。

フォームのバリデーション

Submitを押したときに、TextFormFieldに値がセットされていなかった場合は登録させず、未入力のTextFormFieldに「入力してください」とメッセージを動的に表示させます。

すべてのTextFormFieldのvalidationが、問題ない場合に登録できるようにするには、GlobalKeyを定義し、Formのkey設定に付与する必要があります。

class _RuleFormState extends State<RuleForm> {
  final _formKey = GlobalKey<FormState>() // キーの定義
// ...省略...
    return Form(
      key: _formKey, // キーの設定
// ...省略...

Form以下のフィールド要素(TextFormField)はグループ化されます。Submitボタンが押されたときに、
_formKey.currentState.validate()
を呼ぶことによって、Form()以下のグループ化されたフィールド要素すべてのvalidatorを呼び出し、すべてのvalidatorの戻り値がnullだったときにtrueになります。
validatorがテキストを返したときは、対象フォームにエラーメッセージとして表示されます。

image.png

SnackBar

登録機能はまだないので、SnackBarで「登録しました」メッセージを画面のしたから表示させるだけにしています。

ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('登録しました')));

書き方が少しややこしいですが、SnackBarはScaffoldMessengerの持ち物です。
そのため、context(BuildContext)からウィジェットツリーを辿り、ScaffoldMessengerを見つけ、showSnackBarを呼び出しています。

ここまでの作業内容

ブランチ:5_add_rule_form
https://github.com/s4shiki/if_then_card/compare/4_rule_card_data...5_add_rule_form

画面遷移

TOP画面からAddPageに遷移できるようにします。

TopPageの右上+ボタンとフロートボタンのonPressedを下記のように書き換えます。

lib/page/top_page.dart
import 'package:flutter/material.dart';
import 'package:if_then_card/page/add_page.dart'; // 追加

// トップ画面
class TopPage extends StatelessWidget {
// ...省略...
          IconButton(
            icon: Icon(Icons.add),
            // onPressed: () => print('ルール追加画面に遷移するよ'),
            onPressed: () => Navigator.push( // 追加
              context,
              MaterialPageRoute(builder: (context) => AddPage()),
            ),
          )
// ...省略...
      floatingActionButton: FloatingActionButton(
        // onPressed: () => print('ルール追加画面に遷移するよ'),
        onPressed: () => Navigator.push( // 追加
          context,
          MaterialPageRoute(builder: (context) => AddPage()),
        ),
        child: const Icon(Icons.add),
      ),
    );
  }
}

Navigatorのpush()を呼ぶことで、TopPageの上にAddPageを重ねる(スタック)ことでトップ画面からルール追加画面に遷移させることができます。

次に、AddPageからルールが追加できた場合にTopPageに戻すようにします。

lib/page/add_page.dart
// ...省略
            child: ElevatedButton(
              onPressed: () {
                if (_formKey.currentState.validate()) {
                  ScaffoldMessenger.of(context)
                      .showSnackBar(SnackBar(content: Text('登録しました')));
                  print("登録したよ。条件:$situationText 行動:$actionText");
                  Navigator.pop(context); // TOP画面に戻る
                }
              },
              child: Text("Submit"),
            ),
// 省略...

ElevatedButtonのonPressedで登録できた場合は、Navigator.pop(context)を呼ぶように追加します。

Navigator.pop(context)は、TopPageのNavigator.push()で積んだ自分自身の画面を取り出し、トップ画面を表示することができます。

また、Navigatorのスタックに前の画面が存在する場合は、AppBarの左上には自動で戻るボタンが付与されます。

image.png

MyAppの起動時の画面をTopPageに戻し、+ボタンからAddPageに遷移できるか確認してみましょう。

lib/main.dart
import 'package:flutter/material.dart';
// import 'package:if_then_card/page/add_page.dart'; 削除
import 'package:if_then_card/page/top_page.dart';
// ...省略...
    return MaterialApp(
// ...省略...
      home: TopPage(), // 起動時に表示される画面
      // home: AddPage(), 削除
    );
  }
}

ここまでで、見た目と画面遷移が完成しました!
ui_done.gif

ここまでの内容

ブランチ:6_navigator
https://github.com/s4shiki/if_then_card/compare/5_add_rule_form...6_navigator

モデルの作成

現状だと、ルールデータは、RuleListWidgetのrulesリストとして定義しています。
ルールデータを管理や追加・削除といった振る舞いを用意するためにUIから切り出します。

lib/page/top_page.dart
// ...省略
class RuleListWidget extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final List<Map<String, String>> rules = [
      {'situation': 'ビールが欲しくなったら', 'action': '炭酸水を飲む'},
      {'situation': '食べたくなったら', 'action': 'ナッツを食べる'},
      {'situation': 'タバコが吸いたくなったら', 'action': '二コレスを吸う'},
    ];
// 省略...

libディレクトリに新しく、modelディレクトリを作成します。
構成はこのようにしてみます。

  • lib/model/rule.dart - ルールデータ自体を管理するRuleクラス
  • lib/model/rules.dart - ルールのリストを管理するRulesクラス
lib/model/rule.dart
// ルールデータモデル
class Rule {
  final String situation;
  final String action;

  Rule({this.situation, this.action});
}
lib/model/rules.dart
import 'rule.dart';

// ルールリストモデル
class Rules {
  List<Rule> rules;

  Rules({this.rules});

  // ルールの追加
  void add({String situation, String action}) {
    rules.add(Rule(situation: situation, action: action));
  }

  // ルールの削除
  void delete(Rule target) {
    rules.remove(target);
  }
}

Rulesクラスには、Rule追加と削除用のメソッドも用意しておきます。

状態管理と変更の通知

トップ画面は現在、すべてStatelessWidgetで作成しました。
StatelessWidgetは、それだけでは画面の変更を描画できない不変なウィジェットです。
そのため、ルールカードの追加や削除に応じて画面の表示を動的に変えることができません。

では、StatefulWidgetに書き換えるかぁ?ということになるのですが、それに加えてルールの追加・削除といった変化があった際に変化したことを通知し、再描画する(Stateを変える)必要があります。

※ ルール追加フォームを作ったときの、Formウィジェットでは、フォーカスやエラーメッセージ表示などの動きは、FormStateがsetState()を持っているので変更の通知や描画などは内部でやってくれていたようです。

何を使うのか

沢山の状態管理手法があり初学者には混乱をきたします。

最も基本的な方法は、setState()を呼び出すことによってStatefulWidgetを再描画する方法です。
これは、プロジェクト新規作成時に表示されるサンプルのカウンターアプリのカウントアップ表示に利用されています。

公式でおすすめされているはproviderパッケージを利用する方法です。

なのですが、今回はRiverpodを利用していきます。

Riverpodは、私の観測で流行っているからというのもありますが、Providerパッケージと作者が同一!Providerパッケージの欠点を改善した上位互換!という触れ込みの記事もよく見かけたので、こちらを利用してみることにしました。

参考

Riverpodの導入

flutter_riverpodのパッケージを導入します

pubspec.yaml
# ...省略
dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^0.13.1+1 # 追加
# 省略...

vscodeだと上記を記述しファイルを保存すれば、flutter_riverpodパッケージが自動的に取得されます。

まず、rulesモデルの変更を通知できるようにします。

lib/model/rules.dart
import 'rule.dart';
import 'package:flutter/foundation.dart';

// ルールリストモデル
class Rules extends ChangeNotifier {
  List<Rule> rules = [];

  // ルールの追加
  void add({String situation, String action}) {
    rules.add(Rule(situation: situation, action: action));
    notifyListeners(); // 追加を通知
  }

  // ルールの削除
  void delete(Rule target) {
    rules.remove(target);
    notifyListeners(); // 削除を通知
  }
}

ChangeNotifierクラスを継承し、add()かdelete()が呼ばれたときに、notifyListeners()を呼ぶように変更しました。

notifyListeners()が呼ばれると変更があったことを通知できます。

ウィジェットがRiverpodのChangeNotifirProvider経由で、rulesにアクセスできるように設定していきます。

lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:if_then_rule/model/rules.dart';
import 'package:if_then_rule/page/top_page.dart';

final rulesProvider = ChangeNotifierProvider(
  (ref) => Rules(), // Rulesのインスタンスが生成される
);

// 起動
void main() {
  runApp(ProviderScope(child: MyApp()));
}
// 省略...

ChangeNotifierProviderを変更不可能なグローバル変数(rulesProvider)として定義します。
これにより、ChangeNotifierProviderを介してRules()モデルにアクセス可能になりました。

Providerにアクセス可能な範囲はProviderScopeを指定したウィジェット以下となります。
今回は特にアクセス不可にしたいウィジェット要素もないので、大本のrunAppからProviderScopeを設定しています。

そしたら、まずはトップ画面からルールデータへのアクセスと、ルールの削除を行えるようにしていきます。

lib/page/top_page.dart
// ...省略
import 'package:flutter_riverpod/flutter_riverpod.dart'; // 追加
import 'package:if_then_card/main.dart'; // rulesProviderを取得するために必要
import 'package:if_then_card/model/rule.dart'; // RuleWidgetでRuleクラスを扱うようにしたため追加

// ルール一覧
// class RuleListWidget extends StatelessWidget { // 削除
class RuleListWidget extends ConsumerWidget { // ConsumerWidgetを継承させる
  @override
  Widget build(BuildContext context, ScopedReader watch) {
    // Rulesクラスで管理されるので削除--
    // final List<Map<String, String>> rules = [
    //   {'situation': 'ビールが欲しくなったら', 'action': '炭酸水を飲む'},
    //   {'situation': '食べたくなったら', 'action': 'ナッツを食べる'},
    //   {'situation': 'タバコが吸いたくなったら', 'action': '二コレスを吸う'},
    // ];
    // --削除

    // カードに表示するルールリスト
    final rules = watch(rulesProvider).rules; // rulesProviderからRulesのリストを取得
// ...省略...
    return ListView.builder(
      itemCount: rules.length,
      itemBuilder: (context, index) {
        return RuleWidget(
          // situation: rules[index].situation, 削除
          // action: rules[index].action, 削除
          rule: rules[index], // ruleインスタンスをそのまま渡す
          conditionColor: colors[index % colors.length],
        );
      },
    );
  }
}

// ルールカード
class RuleWidget extends StatelessWidget {
  //final String situation; 削除
  //final String action; 削除
  final Rule rule; // 定義
  final Color conditionColor;

  // RuleWidget({this.situation, this.action, this.conditionColor}); 削除
  RuleWidget({this.rule, this.conditionColor}); // ruleインスタンスを受け取るように変更

  @override
  Widget build(BuildContext context) {
    return Card(
      child: InkWell(
        // onLongPress: () => {print('長押しで削除するよ')}, 削除
        onLongPress: () => context.read(rulesProvider).delete(rule), // Rules.delete()を呼ぶ
        child: Column(
          children: [
            ListTile(
              title: Text(
                // situation, 削除
                rule.situation, // ruleから取得する
                style: TextStyle(fontSize: 20, color: Colors.white),
              ),
              tileColor: conditionColor,
            ),
            ListTile(
              leading: Icon(Icons.subdirectory_arrow_right),
              // title: Text(action), 削除
              title: Text(rule.action), // ruleから取得する
            ),
          ],
        ),
      ),
    );
  }
}

RuleListWidgetは、StatelessWidgetからConsumerWidgetを継承するように変更しました。
ConsumerWidgetは、build()の戻り値で、ScopedReaderを返します。
このScopedReaderから、rulesProviderのRulesインスタンスにアクセスしています。

[抜粋]rulesProviderへのアクセス
  Widget build(BuildContext context, ScopedReader watch) {
    final rules = watch(rulesProvider).rules; // rulesProviderからRulesのリストを取得

また、変更を監視することができ、変更があった場合(RulesのnotifyListeners()が呼ばれた場合)、ConsumerWidget内にあるウィジェットは再描画されるため、リストの表示は自動的に更新されます。

ルールの削除には、Rules.delete()を呼んで引数にRuleインスタンスを渡す必要があります。
そのため、RuleWidgetには、Ruleインスタンスそのものを渡すように変更しました。

Rulesインスタンスへは、ConsumerWidgetではScopedReaderを介してアクセスしていました。
動的にUIを変更する必要がなく(ボタンを押すだけ)、StatelessWidgetからRulesインスタンスにアクセスしたい場合は、context.readが使えます。

[抜粋]rulesProviderへのアクセス
context.read(rulesProvider).delete(rule), // Rules.delete()を呼ぶ

その他にも、状況に応じてプロバイダーへのアクセス方法が複数提供されています。こちらのフローチャートを参考に、何を使えばいいのかを判断することができます。

同様に、context.read()を使い、RuleFormのSubmitボタンではRules.add()を呼ぶことでルールを追加できるようにしましょう。

lib/page/add_page.dart
import 'package:flutter_riverpod/flutter_riverpod.dart'; // 追加
import 'package:if_then_card/main.dart'; // 追加
// ...省略...
child: ElevatedButton(
  onPressed: () {
    if (_formKey.currentState.validate()) {
      ScaffoldMessenger.of(context)
        .showSnackBar(SnackBar(content: Text('登録しました')));
      // print("登録したよ。条件:$situationText 行動:$actionText"); 削除
      context.read(rulesProvider).add(situation: situationText, action: actionText); // Rules.add()を呼ぶ
      Navigator.pop(context);
    }
  },
  child: Text("Submit"),
),
// 省略...

動くようになりました!

final.gif

まだ、ルール情報の永続的な管理や、追加したい機能もあり、このままリリースとはいきませんが、一通り動作するところまで完成しました。

ここまでの作業内容

ブランチ:7_model_riverpod
https://github.com/s4shiki/if_then_card/compare/6_navigator...7_model_riverpod

はじめは、「週末や、ふと思ったときにDIY感覚でアプリが作れたらかっこいいだろうな」という軽い気分から始まったFlutterの学習。まだまだ学習が必要そうです。

52
84
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
52
84

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?