見出し画像

Flutter(Riverpod+StateNotifier+freezed)で作る超簡易体重管理アプリ(サーバーサイド無し)

[更新]2023/10/4 Flutter3系に対応させました✊
[更新]2023/10/4 Riverpod対応の記事を作りました✊


大阪で合同会社Molasoftの代表をしてる向江です!

なんかFlutterの仕事が激増してますんで、みんながFlutterを少しでも早く習得してもらえるようにちょっとだけチュートリアル記事を書きたいと思います。

なおこちらはRiverpodでの対応記事となります。
Providerを利用した記事はこちらとなります!✋
https://note.com/mukae9/n/n91b3301ebccf
(実装する内容は一緒です。)

ちなみに画面の実装もあんま分からん!って人は別で書いているこっちからやってくださいね!

研修向け!Flutter【画面実装ドリル】〜基本編〜

上の記事で画面の実装はある程度出来ている想定で今回の記事ではいわゆる状態管理を行う方法を学んでいきたいと思います。
この記事はProviderを利用しているので、別途同じ内容でRiverpodもバージョンも公開予定です!


状態管理とは

状態管理は簡単にいうと、
画面上の値が変更された時の挙動を管理する感じです。

うん。分かりにくいね。

具体例をあげると、
例えば画面に名前の入力フォームと登録ってボタンがあったとして、そのフォームに文字を打って登録ってしたら画面にその文字が表示されるようにするにはどうしたら良いでしょう??

登録ボタンを押したら引数で次のページに値を渡して表示??

それでも実現できますね。

でもわざわざその文字を表示させるためだけに画面が遷移するのは違和感がありますよね。
できれば登録って押したらパパっと画面に表示させたい。

そんな時に必要になってくるのがこの状態管理です。
スマホアプリの開発には必須で、当然Flutterにも初期の機能としてstatefullウィジェットというものが準備されています。

じゃあこのstatefullウィジェットを使えば良いじゃないかと思うかもしれませんが、どうにもこいつは挙動が重い(らしい)!
例えばさっきのフォームをstatefullウィジェットで実装していると名前のとこだけ画面を再描画するだけで良いのに、実は全画面再描画しちゃいます。(らしい!)

となると非常に無駄な挙動です。

だって例えば凄まじく縦に長いページがあったとして、その中の1文字を変えたいだけでも、なんとそのページ全体を再描画しちゃいますから!

データの読み込みとかあったら尚更重いですね。
ユーザーのためになりません。

APIとかに接続してたら何発も叩きに行く羽目になるかもしれません。
クライアントのためにもなりませんね。

そこで登場したのがProviderそしてさらに次に登場したのが今回扱うRiverpodです。
詳しい解説はぼちぼちやって行きたいと思いますがとりあえずここでは
初期のstatefullでは非効率な部分を補ってくれる良いやつって認識で十分かと思います。


まずは基本の構成を準備!

とにもかくにもこの状態管理を触って見ないと何も始まりません!
Flutterのsampleプロジェクトの準備はOKですか?

まだの方は別の記事などを参考にして準備してくださいね!

ちなみにAndroidStudioでやっています!違う環境だったら色々読み替えてくださいね!大差はないと思うけど!

さて、まずmain.dartをこんな感じにします。
ざっくり消してざっくり貼り付けてください。

import 'package:flutter/material.dart';

import 'pages/my_page/my_page.dart';

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

class MyApp extends StatelessWidget {
 @override
 Widget build(BuildContext context) {
   return MaterialApp(
     home: MyPage(),
   );
 }
}

デフォルトだとmain.dartにどんどん書いていくみたいになってるけど、普通はあり得ないのでとりあえず最初の画面として自分のMyPageに表示をさせる設定で行きたいと思います。

肝心のMyPageクラスを
lib/pages/my_page/my_page.dartとして作成しました!

こういうことね!


MyPageの中身はこんな感じです。

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class MyPage extends StatelessWidget {
  const MyPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('日々の体重を追加していくアプリ'),
      ),
      body: Text('痩せない'),
    );
  }
}

もうこれでとりあえず画面は表示されるはずなのでビルドして見ましょう!

流石に分かると思うけど、右上の緑色の再生マークでビルドです。
あと稲妻マークはホットリロードっていってちょっとしたプログラムの変更ならささっと反映させてくれます。
なるべくこれが良いですね。

なんでビルド→ちょっと変えた→ホットリロード→変わらんやん→ビルドの流れで良いと思います。
ちなみにもうよう分からんくなったら赤い■が停止なので停止させて再度ビルドボタンを押してください。
それでも起動しなかったら君のプログラムのせいです。

画面の実装


さて早速画面が表示されたと思います。

辛い文字がちゃんと表示されてますね。
涙がでます。

それじゃあ早速まずはここに追加をするための簡単なボタンを設置しましょう!
こんな感じです!

もう画面側の実装はそこまで解説しませんので分からない人は
さっきの記事とかでちゃんと学習しててくださいね!

上のデザインを見て画面実装の練習をしたい方はとりあえず一旦ここで止めて実装してから先に進んでくださいね!

さてmy_page.dartはこんな感じにしました!

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class MyPage extends StatelessWidget {
  const MyPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('日々の体重を追加していくアプリ'),
      ),
      body: Center(
        child: Column(
          children: [
            const Text(
              '今日の体重を追加しよう',
            ),
            IconButton(
              icon: const Icon(
                Icons.add_circle,
                color: Colors.blue,
              ),
              onPressed: () {
                debugPrint('押された');
              },
            )
          ],
        ),
      ),
    );
  }
}


IconButtonのonPressedのなかでdebugPrint('押された');って書いてますね!

これもう画面に表示されるんじゃない??

じゃあ、、、

早速画面のプラスボタンを押して見ましょう!


なんもならん!

けどちゃんとなってるところがあります!
Androidstudioの左下のRun(実行)を見て見てください!

出てる!
そうdebugPrint()ってするとここに表示されるんですね。

アプリ自体の実装にはなんの関係もありませんが、
ちゃんと反応してるのか?
なんの値が来ているのか?
とかにめちゃくちゃ使えますので覚えててください!

さてもう少し画面を実装してから、
肝心なProviderを実装しますので、もうちょいお付き合いください。


値が追加されたら表示されるカードを実装しました。
前回と同じくこれを見て画面実装にチャレンジして見てください!
まあ15分くらいでちゃちゃっとね。


さてコードは以下のようになりました。

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';

class MyPage extends StatelessWidget {
  const MyPage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('日々の体重を追加していくアプリ'),
      ),
      body: Center(
        child: Column(
          children: [
            Container(
              height: 100,
              margin: const EdgeInsets.symmetric(
                horizontal: 12,
                vertical: 26,
              ),
              decoration: BoxDecoration(
                color: Colors.white,
                boxShadow: const [
                  BoxShadow(
                    color: Colors.black26,
                    spreadRadius: 1,
                    blurRadius: 10,
                    offset: Offset(10, 10),
                  ),
                ],
                border: Border.all(color: Colors.black),
                borderRadius: BorderRadius.circular(10),
              ),
              child: Row(
                children: [
                  const Padding(
                    padding: EdgeInsets.only(left: 12),
                    child: Text(
                      '100Kg',
                      style:
                          TextStyle(fontWeight: FontWeight.bold, fontSize: 30),
                    ),
                  ),
                  const SizedBox(
                    width: 10,
                  ),
                  Expanded(
                    child: Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      crossAxisAlignment: CrossAxisAlignment.start,
                      children: [
                        Padding(
                          padding: const EdgeInsets.symmetric(vertical: 4),
                          child: Row(
                            children: const [
                              SizedBox(
                                width: 24,
                                child: Icon(Icons.calendar_today),
                              ),
                              SizedBox(
                                width: 8,
                              ),
                              Text(
                                '2020/10/16',
                                style: TextStyle(
                                  fontSize: 12,
                                ),
                              ),
                            ],
                          ),
                        ),
                        Row(
                          children: const [
                            SizedBox(
                              width: 24,
                              child: Icon(Icons.comment),
                            ),
                            SizedBox(
                              width: 8,
                            ),
                            Text(
                              'これは、、、やっちまった、、、',
                              style: TextStyle(
                                fontSize: 12,
                              ),
                            ),
                          ],
                        ),
                      ],
                    ),
                  ),
                ],
              ),
            ),
            const Text(
              '今日の体重を追加しよう',
            ),
            IconButton(
              icon: const Icon(
                Icons.add_circle,
                color: Colors.blue,
              ),
              onPressed: () {
                debugPrint('押された');
              },
            )
          ],
        ),
      ),
    );
  }
}

何回も言いますが画面の実装の解説は前の記事を見てくださいね!


いざ!Riverpod!

RiverpodとはFlutterにおける現在の主流の状態管理パッケージです。
初心者の方には意味わからん単語が連発したと思うので少し説明します。

状態管理とは?

名前の通りと言えば名前の通りなのですが、値の状態を元に画面の表示等を管理するという意味で状態管理です。

例えば、ショッピングサイトのカート画面(おすすめとか)で商品を追加したら、一個増える。削除をしたら一個消える。合計金額の状態も変わっている。
これを実現するためのものです。

よりこの時の処理を噛み砕いてみると。。。
①カート画面を表示する商品がないのでカートが空ですと表示される(商品の状態 0個)
②商品を追加する(商品の状態 1個)
③商品の状態が変わったので自動でカートの項目内に商品が表示される(商品の状態 1個)
④商品をカートから削除する(商品の状態 0個)
⑤商品の状態が変わったので自動でカートの項目内の商品が消えて①と同じ表記になる(商品の状態 0個)

さてこの中でRiverpodがやっている役割は、、、、
「自動で」のところですね。
そうRiverpodに任せて管理させている値に変化があったら勝手に画面を再描画してくれるんです。
じゃあ「自動で」じゃなかったら、、、?
多分画面遷移でもう一回新しくカートページに商品情報を持たせて遷移させるとか、、、?
明らかにめんどくさいですし、カート内商品の表示を変えるだけなのに全体をまた表示させることになるし絶対良くないですね。

ちなみに、Flutterの初期の状態管理としてはStatefullWidgetがありますが、、、色々非効率な面が多いため相当小さな規模でない限りは使うことはないんじゃないかなと思います。
大規模開発で(一部だけとかでなく)全体で使ってたら多分逮捕されます。

さらにちなみにですが、Riverpodの前はProviderがよく使われていました。
自分の受託のプロジェクトは大体がProviderですし、多くのプロジェクトで使われてるので今でも半分主流かと思いますが、Riverpodが正式に1系となりFlutter自体が推奨になってきたので今後開発する際はRiverpodでいいんだと思います。どっちも便利ですし特に勉強コストに差はない&そんなに時間かからないのでお気になさらず。
(余談:flutter_hooksってのは公式がまだ推奨してないから受託では使ってない)



flutter_riverpodをインストール!


pubspec.yamlに以下を追記しpug getをしましょう!
今回の記事で利用する
- riverpod
- freezed
- build_runner(freezedを生成してくれるやつ)
を一気に落としてしまいます!

インデントがずれてるとエラーになるので注意!!!!!

※この記事では自分は3.3.10を利用しています。3系であればバージョン合わせる必要はないかと思いますが、下記のバージョンがあればメッセージをよく読んでバージョンを合わせてあげてください。

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.3.6
  freezed: ^2.3.3
  cupertino_icons: ^1.0.2

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.0
  build_runner: ^2.3.3

pug getを実行でインストール完了です!

StateNotifierをインストール

まずは機能面を書くことができるStateNotifierをインストールしましょう!
と思いましたがRiverpodと仲が良すぎてRiverpodのパッケージに内包されていました。(それだけ一緒に使ってねってこと)

実装開始!

まずは
main.dartでこう!

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

※ProviderScopeで囲む!Riverpodを利用する上でのお決まり事なので無心でOK

次はRiverpodで値を流し込みたいMyPageを少しだけ変えます!
変わってるところわかりますか?

class MyPage extends ConsumerWidget {
  const MyPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {

statelessWidget → ConsumerWidget
build()の中にWidgetRef refを追加!

…。

今できることはこれで終わりです。
え?
ってなりますが画像で言うとこんな事になっています。
(イラストを描く)

状態管理なんで、
- 状態の値自体
- 状態を変更させる機能
が無いんですね。

あくまでRiverpodの役割は状態の変化を監視して自動で再描画!なので状態の値自体と変更させる機能を準備してあげる必要があります。
なので、この記事のタイトルでも出てくる、、、

freezed…直訳すると冷蔵庫。状態の値自体を保管できます。
stateNotifier…状態を変更させたりとか、状態関係なくアプリとしての機能を書いたりもする。

を利用する事になります!
ちなみにこの子たちにも代替えの存在がありますが、、、今の所まあ一番主流かなと思っています。

StateNotifierのファイルを作ろう


まずは必要StateNotifierのファイル、
my_page_notifier.dartをmy_page.dartと同じフォルダに作りましょう!

中身は一旦こんな感じにしましょう!

import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

part 'my_page_notifier.freezed.dart';

@freezed
abstract class MyPageState with _$MyPageState {
  const factory MyPageState({
    @Default(0) int count,
  }) = _MyPageState;
}

class MyPageNotifier extends StateNotifier<MyPageState>  {
  MyPageNotifier({
    required this.context,
  }) : super(const MyPageState());

  final BuildContext context;

  @override
  void dispose() {
    debugPrint('dispose');
    super.dispose();
  }

  @override
  void initState() {}
}

赤い部分はあると思いますが、今はそのままで大丈夫です。

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:state_notifier/state_notifier.dart';

などで先ほど落としたパッケージをImportしています。
このおかげでStateNotifierとかLocatorMixinとか@freezedとか色々使えるようになっています。

正直

@freezed
abstract class MyPageState with _$MyPageState {
 const factory MyPageState({
  @Default(0) int count,
 }) = _MyPageState;
}

class MyPageNotifier extends StateNotifier<MyPageState> with LocatorMixin {
 MyPageNotifier({
   @required this.context,
 }) : super(const MyPageState());

ここらあたりは、
解説するにも、、、、
パッケージで指定されてるのでこういう書き方です☺️
なぜ書くのかとかを詮索するのは野暮ですね。
パッケージのドキュメントにも「こう使え!」としか書いていません。

結局ここもさっきと同じように他のとこで使いときはMyPageStateとかの部分を該当のクラス名に変えれば良いだけです!

 @override
 void dispose() {
   debugPrint('dispose');
   super.dispose();
 }

 @override
 void initState() {}
}

ここら辺は軽く解説しておきます。
void dispose()
これはその画面から戻った時などに自動的に発動するメソッドです。
例えば今後画面表示のために状態を管理して行きますが別のページに戻ったりした際にその管理状態がそのままだったらすんごい無駄ですよね。
そこをsuper.dispose();でしっかり削除しちゃいます。

あとその画面から移動する際に何かしらの処理を走らせたい時などにも使えますね。
void initState() {}
は名前から連想しやすいですが、
このページが表示された際に自動的に走る処理を書くところです。
例えばMyPageに表示させる情報をdbなどに保存していたらそこから取ってくる処理とか。

つまり出口と入口で自動で発動できるメソッドと捉えてくれたらOKです!

@freezedのところはまた後で解説しますので、
一旦my_page.dartに戻りましょう。

さてまだ赤いと思いますが、
必要なファイルは作成できたので簡単に解決できます。

もう知ってるかも知れませんが、
赤い線が走ってるところの上でカーソルを一時停止させたらなんか解決策を教えてくれます。
そして画像の吹き出しの左下に青色でImport~~~って書いてますよね!
そう、さっき作ったmy_page_notifier.dart必要?って聞いてくれてるんですね。

もちろん必要なのでその青色の文字をクリックしてください。
勝手にImportの記述をしてくれます。

さらにStateNotifierProviderとかでもカーソルを合わせるとどんどんImportの作業をしてくれます。

AndroidStudio最強!

基本的にこの動きでImportをすることをお勧めします。
手作業でやるとまあミスります。
楽するところは楽しましょう!

StateNotifierをpageの方で使えるようにする

お次はMyPageの方でmyPageNotifierを使えるようにしてあげましょう。
Riverpodはグローバルに定義しちゃえばどこでだってすぐ使えることがメリットですね。
Providerでは使うためにはその記述をしないといけませんでした。

ファイルを分けて実装する方が綺麗ですが今回は解説のためにもmain.dartに定義してあげちゃいます。

import 'package:demo/pages/my_page/my_page_notifier.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'pages/my_page/my_page.dart';

/// ここでグローバルに定義する
final myPageProvider =
    StateNotifierProvider<MyPageNotifier, MyPageState>((ref) {
  return MyPageNotifier();
});

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      home: MyPage(),
    );
  }
}


これでこのプロジェクトではmyPageProviderをどこからだって呼び出せます。

この開通した情報を使うには、Page側の方で
ref.watch()
ref.read()
を使用します。
refはよく見たらbuildの引数にあるやつですね。
Riverpodそのものと言ってもいいかもしれません。

ref.watch()はこんな感じで利用します。

final state = ref.watch(myPageProvider);
//stateにcountが入ってる

ちなみに
ref.read()も


final state = ref.read(myPageProvider)

って感じで使えます。
何が違うんだ!って言うのはだいぶ違うので後で体験してみましょう!

ちなみにnotifier自体は、

final myPageNotifier = ref.read(topPageProvider.notifier)
//myPageNotifier.honya()でメソッドが実行される。

こんな感じで取れます。

class MyPage extends ConsumerWidget {
  const MyPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final myPageState = ref.watch(myPageProvider);
    final myPageNotifier = ref.watch(myPageProvider.notifier);
    return Scaffold(

〜略〜

Providerではより多くのつなげるための実装が必要でしたがRiverpodはこれだけですね。

ConsumerWidgetに変更しているためその中のref.watch(myPageProvider)によってmyPageProviderの値が変更された場合はこの範囲のWidgetを再描画すればいいんですね!って理解してくれます。

これでmy_page_notifier側のstate情報が変わった瞬間にmy_page.dart側の画面を再描画、、、!のような事ができます!

ちょっと初心者の方は何言ってるかわかんないと思いますので、
「まあ便利なんね。」
程度で実装してください。
MyPageNotifier, MyPageStateとかの部分を該当のクラス名に変えれば動きますので。
Flutter自体に慣れてからより詳しく理解して言ってください。

プログラミングの世界やと多分基本的に、
完全理解→実装ってことは滅多にないです。
実装→意味わからんけど動く→慣れる→理解です。

まずはやっちゃいましょう☺️

意味不明でも動くなら良いのさ(最初は)

さて、
なんか良いこと言った風にして解説を端折りましたが
今のところまだコードが真っ赤だと思いますので、

そんなこんなで最終的にこうなりました。
もう赤いのは無くなりましたね!

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_state_notifier/flutter_state_notifier.dart';
import 'package:provider/provider.dart';

import 'my_page_notifier.dart';

class MyPage extends ConsumerWidget {
  const MyPage({super.key});


 @override
 Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(myPageProvider);
    final notifier = ref.watch(myPageProvider.notifier);
   return Scaffold(
     appBar: AppBar(
       title: Text('日々の体重を追加していくアプリ'),
     ),
     body: Center(
       child: Column(
         children: [
         
         〜略〜
         
     
     


freezed


さて必要なファイルがあとひとつだけあります。

freezedです。

freezedのファイルはbuild_runnerというやつで自動生成させます!
なので自分で触ることは基本的にありません!

とりあえずますは早速ファイルを自動生成してみましょう!
コマンドでプロジェクトのルートディレクトリで実行します!

打つコマンドは、

 flutter pub run build_runner build --delete-conflicting-outputs

です!

すると!

my_page_notifier.freezed.dartが自動で生成されていますね!

早速中身をみてみましょう!

// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named

part of 'my_page_notifier.dart';

// **************************************************************************
// FreezedGenerator
// **************************************************************************

T _$identity<T>(T value) => value;

class _$MyPageStateTearOff {
 const _$MyPageStateTearOff();

 _MyPageState call({int count}) {
   return _MyPageState(
     count: count,
   );
 }
}

// ignore: unused_element
const $MyPageState = _$MyPageStateTearOff();

mixin _$MyPageState {
 int get count;

 $MyPageStateCopyWith<MyPageState> get copyWith;
}

abstract class $MyPageStateCopyWith<$Res> {
 factory $MyPageStateCopyWith(
         MyPageState value, $Res Function(MyPageState) then) =
     _$MyPageStateCopyWithImpl<$Res>;
 $Res call({int count});
}

class _$MyPageStateCopyWithImpl<$Res> implements $MyPageStateCopyWith<$Res> {
 _$MyPageStateCopyWithImpl(this._value, this._then);

 final MyPageState _value;
 // ignore: unused_field
 final $Res Function(MyPageState) _then;

 @override
 $Res call({
   Object count = freezed,
 }) {
   return _then(_value.copyWith(
     count: count == freezed ? _value.count : count as int,
   ));
 }
}

abstract class _$MyPageStateCopyWith<$Res>
   implements $MyPageStateCopyWith<$Res> {
 factory _$MyPageStateCopyWith(
         _MyPageState value, $Res Function(_MyPageState) then) =
     __$MyPageStateCopyWithImpl<$Res>;
 @override
 $Res call({int count});
}

class __$MyPageStateCopyWithImpl<$Res> extends _$MyPageStateCopyWithImpl<$Res>
   implements _$MyPageStateCopyWith<$Res> {
 __$MyPageStateCopyWithImpl(
     _MyPageState _value, $Res Function(_MyPageState) _then)
     : super(_value, (v) => _then(v as _MyPageState));

 @override
 _MyPageState get _value => super._value as _MyPageState;

 @override
 $Res call({
   Object count = freezed,
 }) {
   return _then(_MyPageState(
     count: count == freezed ? _value.count : count as int,
   ));
 }
}

class _$_MyPageState with DiagnosticableTreeMixin implements _MyPageState {
 const _$_MyPageState({this.count});

 @override
 final int count;

 @override
 String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
   return 'MyPageState(count: $count)';
 }

 @override
 void debugFillProperties(DiagnosticPropertiesBuilder properties) {
   super.debugFillProperties(properties);
   properties
     ..add(DiagnosticsProperty('type', 'MyPageState'))
     ..add(DiagnosticsProperty('count', count));
 }

 @override
 bool operator ==(dynamic other) {
   return identical(this, other) ||
       (other is _MyPageState &&
           (identical(other.count, count) ||
               const DeepCollectionEquality().equals(other.count, count)));
 }

 @override
 int get hashCode =>
     runtimeType.hashCode ^ const DeepCollectionEquality().hash(count);

 @override
 _$MyPageStateCopyWith<_MyPageState> get copyWith =>
     __$MyPageStateCopyWithImpl<_MyPageState>(this, _$identity);
}

abstract class _MyPageState implements MyPageState {
 const factory _MyPageState({int count}) = _$_MyPageState;

 @override
 int get count;
 @override
 _$MyPageStateCopyWith<_MyPageState> get copyWith;
}

みない方が良かったですね。
正直見なくても理解できなくても大丈夫です。

というかこの記事をやっているということは初期の勉強あたりか、上司からとりあえずこの記事やっててって投げられた状況かと思いますので、
今触れる部分ではないかと思います。
どうしてもfreezedについて知りたい人はこういった記事を参考にしてください。

とにかくここで覚えておくことはfreezedは色々便利なことやってくれているくらいで大丈夫!
まずは理解する前に使えるようになりましょう!
リモコンの中身なんて知らんくても使えますよね!

さてこれで
provider
stateNotifier
freezed
の準備が完了しました。

パッケージの中身の詳細はわからなくてもこの準備の方法はしっかり次もできるようにしてくださいね。

多分タスク的に
「〇〇さんはじゃあtop_page作ってstateNotifierとfreezedも作って繋げといて」的な感じになります。(ちょっと極端かもだけど)

そしたら何をすれば良いかわかりますか??
pubspec.yamlで必要なパッケージがまだ落とせてないなら落として、
top_page_notifier.dart作って中身はとりあえずコピペしてクラス名だけ変えて、、、
ってことですからね!

Riverpod+StateNotifier+freezedを利用しよう!


さて、いよいよこれらを利用して行きましょう!
ここからが一番みなさんが実する部分になってくると思います。

まずは改めてもう少し詳しくMyPageNotifierクラスの方の説明をさせてもらいます。

import 'package:flutter/cupertino.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

part 'my_page_notifier.freezed.dart';

@freezed
abstract class MyPageState with _$MyPageState {
  const factory MyPageState({
    @Default(0) int count,
  }) = _MyPageState;
}

class MyPageNotifier extends StateNotifier<MyPageState> {
  MyPageNotifier({
    required this.context,
  }) : super(const MyPageState());

  final BuildContext context;

  @override
  void dispose() {
    debugPrint('dispose');
    super.dispose();
  }

  @override
  void initState() {}
}

まずここ

@freezed
abstract class MyPageState with _$MyPageState {
 const factory MyPageState({
  @Default(0) int count,
 }) = _MyPageState;
}

さっきも出てきたfreezedを生成させるための場所です。

ここにこっそり
int count 
ってあるのが分かるかと思います。

これが状態管理されている実際の値ですね。
このint型のcountという変数の値が変わったことを探知できるようになっています。

@Defaultはその名前の通り初期値です。

ちなみにここに
string型のcommentを追加したとすると、

@freezed
abstract class MyPageState with _$MyPageState {
 const factory MyPageState({
  @Default(0) int count,
  String? comment, 
 }) = _MyPageState;
}

こうなります。

昔はString commentでよかったんですが、Flutter2系からだとダメで、この変数がnullを許容するかどうかを書かないといけないので、String? commentとします。
本当はデフォルト値を持たせたりすることも考えないといけないですが、この記事ではnull-safetyを説明した記事ではないので一旦今後出てくるやつは全て、
型に?をつけて(String?とかint?とか)nullを許容してあげてください!
ビルドランナーが通らん!って時は多分この?を忘れてます!

そして赤色のエラーの線が出ます。

理由は実際にfreezedファイルの方にこの値がまだ生成されてないからですね。
ここに値を追加したら残念ながらもう一度build_runnnerを発動しないといけません。

 flutter pub run build_runner build --delete-conflicting-outputs

これですね。
するとstring commentの情報が追加された
my_page_notifier.freezed.dartが上書きで生成されます。

Stateの情報の変更

それでは早速ですが今まで頑張って準備してきた彼らを使ってみましょう!
まずは値の変更です。

まずはこっそり残していたcountを使って感覚を掴んでみましょう!

先ほどのこの青丸のプラスボタンを押したらcountが1づつ増えていく仕様にしたいと思います。

まずはmy_page_notifier.dartの方に
プラスボタンを押したときの挙動を書いてみましょう!

~略~

 @override
 void initState() {}


 void pushButton(){
   debugPrint('notifier!!');
 }
}

軽くdartでのメソッドの書き方を説明しておきます。 

void(返り値なし) メソッド名(ここに引数){
    なんか実際の処理
}

特に解説することもないですね。
他の言語と同じです。

初心者の人はメソッドわからないわからない言うんですけど、
これに意味を求めようとするからです。
僕らからしたら1+1=2になるのが分からない分からない、、、っていってるのと同じなので、
こうなるんだ!!っで一種の式として覚えてください。

ちなみにvoidの部分がintとかstringとかの返り値の値になったりします。
例えばstringだったらこう言うことですね。

 string メソッド名(ここに引数){
    return nankamozi;
}

voidは返り値がないんで、returnも不要でしたがstringとかにすると
「え?returnするんじゃろ?」ってdartが怒ってきますんで必ずreturnを書いてあげてください。
あとstringを返すっていってるのに実際の中の値が数字だったりするとエラーがまた出ます。
dartは型が強い言語ですからね。
多分きっと今後も型の違いに苦しめられることでしょう(特に自分)

さて話を戻して、pushButton()メソッドが発動されるとnotifier!!ってコンソールに表示されるメソッドを作りました。

これをmy_page.dart側で読んであげる必要があります。
と言ってももうやってますね。この呪文が必要です。

final notifier = ref.watch(myPageProvider.notifier);

ref.watchでMyPageNotifierにアクセスしています。

そしてのその返り値をnotifierと言う変数に代入しました。
このnotifierにはMyPageNotifierの情報がつまってることになります。

それじゃあ次に実際にさっき使ったメソッドを使ってみましょう!

IconButtonウィジェットのonPressedの中身を

Text(
 '今日の体重を追加しよう',
),
IconButton(
 icon: Icon(
   Icons.add_circle,
   color: Colors.blue,
 ),
onPressed: () {
   notifier.pushButton(); //編集
 },   
)

に変更します。
これだけです!
早速ビルドして確認してみましょう!

きました!!
ちゃんと動いています!!

ただこれではイメージ的にはインスタンス化したクラスのメソッドを使ってるだけなので、ここからが大事です。

countに1を加える処理を追加します。

my_page_notidier.dartのpushButtonメソッドで

  void pushButton() {
   debugPrint('notifier!!');
   state = state.copyWith(count: state.count + 1);
   debugPrint(state.count.toString());
 }

をこんな記述に変更します!

state = state.copyWith(count: state.count + 1);

これが一番覚えて欲しいところです!
先ほどの@freezedの中にあったcountの値を変更するための処理です。

まずは値を変更する記述です。

state = state.copyWith(値の名前: 値);

この書き方をしないと値は変更されません!
よく覚えててください!

またもう一つ覚えて欲しいところは、

state.count

です!
これで現在のcountの値を取得しています!

実際にコンソールを見てみると
プラスボタンを押すたびにcount up していますね!


Stateの情報の取得と表示

情報の変更が出来たので次は取得して画面に表示させてみましょう!
state情報の取得には、すでに記述しているこちらから取得できます。

final state = ref.watch(myPageProvider);
debugPrint(state.count);

/// こういう取り方でもOK
final count = ref.watch(myPageProvider.select((value) => state.count));

こんな感じですね。
これでstate.countを監視して変更があった際に画面を再描画するようになります。

実際に利用してみます!
まずは利用の宣言の部分

~略~
 
 @override
 Widget build(BuildContext context) {
   final notifier = context.watch<MyPageNotifier>();
   final count = context.select((MyPageState state) => state.count); //ここ
   debugPrint('描画'); //ここもついでに
   return Scaffold(
     appBar: AppBar(
       title: Text('日々の体重を追加していくアプリ'),
     ),
     body: Center(
       child: Column(


~略~

次に実際に利用する部分!
さっきまで100kgって表示されていたところです。
どさくさにデザイン的なPaddingをContainerに変えてwidthも指定したりしてます。

child: Row(
children: [
 Container(
   padding: const EdgeInsets.only(left: 12),
   width: 100,
   child: Text(
     count.toString(), //ここ
     style:
         TextStyle(fontWeight: FontWeight.bold, fontSize: 30),
   ),
 ),

エラーでましたよね??

TextWidgetがよくないようなエラーに見えてしまいますがこれは親のPaddingにconstをつけてしまってることによって起こっています。
constは簡単に言うと「値が確定してますよ!なので効率的にどうぞ!」っていう宣言なのでcountという変数が発生したことで使えなくなっちゃったんですね。
エラー文の中にconstantって文字が出たらすぐにconstのせいだ!って反応できるようにしておきましょう!

Paddingのconstを消して、必要なところにconstを加えたら次に進みます。

count.toString(),

この部分ですが、
Text()ウィジェットは引数に文字列を求めるのでint型のcountはそのままは入らないので、
ちゃんと.toString()で文字列としての数字に変換してあげます。
結構この.toString()はいろいろな部分で利用するので文字列じゃない!!って怒られた時は使ってみてください!
先ほどのdebugPrintにもどさくさに使っています。

さてボタンを押してみましょう!

連打すればどんどん数字が増えますね!

当たり前のように感じてしまうかもしれないけど、
stateNotifierに書いたState情報のcountの値が変更された瞬間に、ページ側で監視してるcount情報も変更・再描画されると言うことが行われています。

Builder()をちゃんと使う


しかしこれだけではダメです。
それは先ほど、どさくさに書いていたdebugPrint('描画')でわかるのですが、
コンソールを見るとプラスボタンを押すたびに描画と表示されているのがわかります。

つまりどう言うことかというと結局、数字が変わるだけなのに画面全体が変わってしまってるってことです。
これだとRiverpodを使う意味がありません、、、。

原因としてはメインのbuildメソッド直下で
final count = ref.watch(myPageProvider.select((value) => state.count));
をしてしまっているためです。
これだと再描画範囲が全部になってしまうんですね。


そこで画面を再描画する範囲を特定するために
Consumer()メソッドを利用します。(ProviderだとBuilderでしたね!)

とりあえず実装方法を見てみましょう!

まずは先ほど書いていた部分を一部削除して、

  @override
 Widget build(BuildContext context, WidgetRef ref) {
   final notifier = ref.watch(myPageProvider.notifier);
   //削除
   debugPrint('描画');
   return Scaffold(

100Kgの部分をこういう感じに編集します。

child: Row(
   children: [
        Consumer(
      builder: (context, ref, child) {
           final count = ref.watch(myPageProvider.select((value) => value.count));
     return Container(
       padding: const EdgeInsets.only(left: 12),
       width: 100,
       child: Text(
         count.toString(),
         style: TextStyle(
             fontWeight: FontWeight.bold, fontSize: 30),
       ),
     );
    },
),

Consumerメソッドで囲って、
builder: (context, ref, child) {}
の中でWidgetを返すようにしています。
残りはいつものWidgetと同じです。

少し難しいかもしれませんが、理解というよりは慣れです。
慣れてください!

そしてこの中で

final count = ref.watch(myPageProvider.select((value) => value.count));

を利用しているため、再描画範囲がこの領域に特定されることになります。

実際にコンソールでも、

Builderの外にあるdebugPrint('描画')が再描画されていないのがわかります!
ならない人はちゃんとビルドし直してくださいね。ホットリロードだと反映しきってないかもです。
これで必要な部分だけを再描画することが可能となりました。

これをやっている意味が分からない人は、

逆にやらないとなると、
例えるならクツだけ履き替えたい時に、
なぜかTシャツも脱いで、クツを履き替えて同じTシャツを着る人なんていないですよね。
とてつもなく無駄な労力です。
けどそれをやってることになります。

例えが微妙なのは分かってるんで分かる努力をしてください。

ように無駄なメモリを喰うと。

Tシャツだけならまだしも、ズボンもみたいことになってくるとドンドン作業が重くなるので、ちゃんと靴だけ履き替えれるようにしてる作業がこれです。

ポップアップの表示


それじゃあそろそろ実際の処理を作っていきましょう!
今はplusボタンを押すとカウントアップしちゃってますがこのボタンを押すとどうしたいですか??

そうですね。
ポップアップでフォームを出したいですね!

この記述は画面の実装なのでmy_pageに書いちゃいます!
メソッド名をpopUpFormとかにして新規で作りましょう!

もちろんmy_page.dartで+ボタンを押した際に発動させるメソッドをpopUpFormに変えてくださいね。

ちょっと難しいかもしれませんが、こんなの出してみましょう!



showDialog()ってのを使っています。
もっと言うと

   showDialog(
     context: context,
     builder: (context) {
       return SimpleDialog(
         title: Text('今日の体重を入力しよう'),
         contentPadding: EdgeInsets.symmetric(
           horizontal: 14,
           vertical: 24,
         ),
         children: [

    あとはがんばれ!

です!
数十分チャレンジしてみて無理なら先に進んで答えを見てちゃんと自分で実装して見てくださいね!

答えです!

              '今日の体重を追加しよう',
            ),
            IconButton(
              icon: const Icon(
                Icons.add_circle,
                color: Colors.blue,
              ),
              onPressed: () {
                // notifier.pushButton();
                popUpForm(context); //これ追加
              },
            )
          ],
        ),
      ),
    );
  }
}

void popUpForm(BuildContext) {
   showDialog(
     context: context,
     builder: (context) {
       return SimpleDialog(
         title: Text('今日の体重を入力しよう'),
         contentPadding: EdgeInsets.symmetric(
           horizontal: 14,
           vertical: 24,
         ),
         children: [
           Row(
             children: [
               Container(
                 width: 200,
                 padding: EdgeInsets.only(left: 4),
                 child: TextFormField(
                   decoration: InputDecoration(
                       border: OutlineInputBorder(),
                       hintText: '嘘つくなよ',
                       labelText: '今日の体重'),
                 ),
               ),
               SizedBox(
                 width: 10,
               ),
               Text('Kg'),
             ],
           ),
           SizedBox(
             height: 20,
           ),
           Container(
             width: 200,
             padding: EdgeInsets.only(left: 4),
             child: TextFormField(
               decoration: InputDecoration(
                   border: OutlineInputBorder(),
                   hintText: '後悔先に立たず',
                   labelText: '懺悔の一言'),
             ),
           ),
           SizedBox(
             height: 20,
           ),
           InkWell(
             child: Container(
               margin: EdgeInsets.symmetric(horizontal: 40),
               padding: EdgeInsets.all(4),
               decoration: BoxDecoration(
                 color: Colors.blue,
                 border: Border.all(color: Colors.blueAccent),
                 borderRadius: BorderRadius.circular(20),
               ),
               child: Text(
                 '登録',
                 textAlign: TextAlign.center,
                 style: TextStyle(color: Colors.white),
               ),
             ),
             onTap: () {},
           )
         ],
       );
     },
   );
 }


体重とコメントを一時保存する


さあ、これで+ボタンを押すとポップアップが表示されました!

次はどうしましょうか?

登録ボタンを実装するも良いですが先にフォームの内容をstateに保存していく部分を実装したいと思います。

体重の方から行きます。

  void popUpForm(BuildContext context,MyPageNotifier notifier) {
   showDialog(
     context: context,
     builder: (context) {
       return SimpleDialog(
         title: Text('今日の体重を入力しよう'),
         contentPadding: EdgeInsets.symmetric(
           horizontal: 14,
           vertical: 24,
         ),
         children: [
           Row(
             children: [
               Container(
                 width: 200,
                 padding: EdgeInsets.only(left: 4),
                 child: TextFormField(
                   decoration: InputDecoration(
                     border: OutlineInputBorder(),
                     hintText: '嘘つくなよ',
                     labelText: '今日の体重',
                   ),
                   onChanged: (value) { //ここ
                     notifier.saveWeight(value);
                   },
                   keyboardType: TextInputType.number, //ここ
                 ),
               ),
               
    ~略~

MyPageNotifier notifier
onChanged:
keboardType:
の二つを追加しました。

MyPageNotifier notifierはメソッドの引数ですね。このメソッドでもnotifierを使いたいので追加です。

keyboardType:は実際にデバイスで表示されるキーボードのタイプを指定できます。
体重なんで数字のはずなのでnumberに指定しました。
下の画像で見たらわかりますが数字のキーボードが表示されるはずです。
(Keyboardが設定で表示されない場合は検索!)

次にonChangedはフォームの変更を探知してイベントを走らせてくれます。
つもり1文字打ったり消したりするたびにここに書いた処理を走らせます。

書き終えた時に発動させた方が良いのでは??と思う方もいるかもしれませんがそれではバリデーションで違反した瞬間にエラーが出せないので自分はこれをよく使っています。

onChanged: (value) { 
 notifier.saveWeight(value);
},

ちなみにonChangedのこのvalueにはフォームの値が自動的に入ります。

saveWeightはStateを操作しますのでmy_page_notifierの方に書いてあげましょう。とりあえずこんなメソッドを作っておきました。

  void pushButton() {
    debugPrint('notifier!!');
    state = state.copyWith(count: state.count + 1);
    debugPrint(state.count.toString());
  }

  void saveWeight(String value) {
    debugPrint(value);
  }



さて、これで体重の値を入力すると、、、、。

値が変更されるたびにsaveWeight()が走っていますね!!

さてここで実践問題です!

先ほどcountの値をstateで管理したように
今回はweightという変数を作ってbuild_runnnerとかを同じく走らせるなどなどして、

最終的にsaveWeight内で
debutPrint(state.weight);
をすることでコンソールに値が表示されるようにしてみてください!

ちなみに本当はint型でweight変数を作りたいところですがちょっとめんどくさくなるので今回の教材ではString型のweightでOKです!

やり方は上に書いているので、
ぜひ見返しながら頑張ってみてください!


〜〜


それじゃあ出来たという想定でやっていきます!

まずは
@freezedにString型のweightを作ります。

@freezed
abstract class MyPageState with _$MyPageState {
 const factory MyPageState({
   @Default(0) int count,
   String? comment,
   String? weight,
 }) = _MyPageState;
}

そしてbuild_runnerを走らせます!

 flutter pub run build_runner build --delete-conflicting-outputs

すると自動でfreezedが更新されます。

次は今作ったweightに情報を入れていきましょう!

state情報の値を変更するにはcopyWithを使いましたね!
これで値が入れられるはずです☺️

  void saveWeight(String value) {
   state = state.copyWith(weight: value);
   debugPrint(state.weight);
 }

コンソールに表示されるやつもちゃんとフォームに入力したやつが表示されていると思います!!

さあ、この勢いでコメントの方も実装してみましょう!
メソッドの作成から何からちょっと自分で考えてやってみてください!!
やって欲しいことは体重と同じくコメントをstateに保存するにする処理です!
ヒントは上にたくさん書いてありますよ〜!


〜〜



さあ行けましたかね?
もし行けてないと正直よくないので、なんとか自分で出来るようにいっそこの教材を最初からやり直す勢いで理解をもう一度行ってください!

答えです!

@freezedのところ(build_runnerしてね)

@freezed
abstract class MyPageState with _$MyPageState {
 const factory MyPageState({
   @Default(0) int count,
   String? weight,
   String? comment,
 }) = _MyPageState;
}

新しく作ったメソッドです。

  void saveComment(String value) {
   state = state.copyWith(comment: value);
   debugPrint(state.comment);
 }
 

そのメソッドを呼ぶ部分。

void popUpForm(BuildContext context, MyPageNotifier notifier) {
  showDialog(
    context: context,
    builder: (context) {
      return SimpleDialog(
        title: const Text('今日の体重を入力しよう'),
        contentPadding: const EdgeInsets.symmetric(
          horizontal: 14,
          vertical: 24,
        ),
        children: [
          Row(
            children: [
              Container(
                width: 200,
                padding: const EdgeInsets.only(left: 4),
                child: TextFormField(
                  onChanged: (value) {
          
                    notifier.saveWeight(value);
                  },
                  keyboardType: TextInputType.number, 
                  decoration: const InputDecoration(
                      border: OutlineInputBorder(),
                      hintText: '嘘つくなよ',
                      labelText: '今日の体重'),
                ),
              ),
              const SizedBox(
                width: 10,
              ),
              const Text('Kg'),
            ],
          ),
          const SizedBox(
            height: 20,
          ),
          Container(
            width: 200,
            padding: const EdgeInsets.only(left: 4),
            child: TextFormField(
              decoration: const InputDecoration(
                  border: OutlineInputBorder(),
                  hintText: '後悔先に立たず',
                  labelText: '懺悔の一言'),
              onChanged: (value) {
                notifier.saveComment(value); //これ!!!!!
              },
            ),
          ),
          const SizedBox(
            height: 20,
          ),
          InkWell(
            child: Container(
              margin: const EdgeInsets.symmetric(horizontal: 40),
              padding: const EdgeInsets.all(4),
              decoration: BoxDecoration(
                color: Colors.blue,
                border: Border.all(color: Colors.blueAccent),
                borderRadius: BorderRadius.circular(20),
              ),
              child: const Text(
                '登録',
                textAlign: TextAlign.center,
                style: TextStyle(color: Colors.white),
              ),
            ),
            onTap: () {},
          )
        ],
      );
    },
  );
}

これでちゃんとコメントもstateで管理されるようになりました!

これで必要な体重の値と、コメントは管理できるようになったので、
次は登録ボタンを実装してみましょう!

登録ボタンで何をするかですが、

・stateで管理してる体重を取得
・stateで管理してるコメントを取得
・現在の日付を取得
・新しいstateを使って保存&画面に表示

です!!

ちょっと難しくなってきましたね。
早速registerメソッドを作っていきたいと思います。​

ListとMap

解説の前に@freezedの部分です。

@freezed
abstract class MyPageState with _$MyPageState {
 const factory MyPageState({
   @Default(0) int count,
   String weight,
   String comment,
   @Default([]) List<Map<String, String>> record,
 }) = _MyPageState;
}

@Default([]) List<Map<String, String>> record,
が追加されています。

初見では気色悪いですね、、、。

まず分解して説明します。

まず見ないといけないところは、
List<Map<String, String>>
です。

そしてさらに分解して

List<型>

です。

これは配列ですね。
PHPとかJavascriptでも出てくる配列です。
['A', 'B', 'C']
これならList<String>になります。

[1, 2, 3]
これなら
List<int>ですね。

ただ今回はその型の部分にMap<String, String>が入っているんで複雑に見えます。
Map<String, String>は

PHPとかJavascriptでいう連想配列ですね。

{'name': 'value' ,  'comment': 'value' }

これだとMap<String, String>です。

{'id': 1,  'age': 23 }
これならMap<String, int>です。

つまり今回のrecordという変数は、
{'weight':'値', 'comment': '値', 'day': '日付'}というのを入れたいと思っているんですが、

つまりMap<String, String>形のやつが、
[
   {'weight':'値', 'comment': '値', 'day': '日付'},
   {'weight':'値', 'comment': '値', 'day': '日付'}
]
こんな感じで配列で入りますよってことです。
配列の中に連想配列があるってことですね。

次に実際にregisterメソッドです。
notifierの方に書きましょう!

  void register() {
    final formRecord = {
      'weight': state.weight!,
      'comment': state.comment!,
      'day': DateTime.now().toString(),
    };
    debugPrint(formRecord.toString());
    final newRecord = List<Map<String, String>>.from(state.record);
    newRecord.add(formRecord);
    state = state.copyWith(record: newRecord);
    debugPrint(state.record.toString());
  }
 

また色々書いてます、、、。
でもちゃんと分解して読み解いていくと実はそれほど難しくはありません。

今さらっといった問題の分解は非常に大事です。
プログラミングに置いてもどんな仕事においても。

一気に全部解決するのは難しいので何事も小さく分解してから考えていく癖をつけておくと良いと思います。 ​

さてまずはformRecordという変数を見てみましょう。

final formRecord = {
    'weight': state.weight!,
    'comment': state.comment!,
    'day': DateTime.now().toString(),
  };
 

今更ですがfinalってのは再代入不可になることを意味しますが、さらに代入される値で判断して型を設定してくれるので型の指定の省略が可能となります。

省略しなかった場合は、

final Map<String,String> formRecord = {
    'weight': state.weight,
    'comment': state.comment,
    'day': DateTime.now().toString(),
};

ということになります。
Map<String,String>はさっき出てきましたね。
つまりformRecordは文字列:文字列の連想配列です。
そしてweightという値には先ほど出てきたstate.weightでstate情報を取得しており、commentも同じくです。
dayだけは
DateTime.now()というdartの初期のメソッドを使って現在の日付を取得しています。
DateTime.now()だけだと、Map<String,String>である以上型の不一致が発生しますので.toString()を利用することで型を一致させています。

さあどんどんいきましょう。

   final newRecord = List<Map<String, String>>.from(state.record);
   newRecord.add(formRecord);
   state = state.copyWith(record: newRecord);

List<Map<String, String>>.from(state.record)
この部分は先ほど@freezedで作成したstate.recordを利用して新しく配列を新規作成しています。

こうしなければ、
残念ながらstate.record.add()ではエラーが発生しちゃいます。
なので一旦新規のnewRecordというコピーを作ってから
そのnewRecordという配列に対してaddでformRecordという先ほどの体重などの情報が入った連想配列を追加しています。

そして最後に今フォームから送られてきたformRecordも新規で加わったnewRecordでstate.recordをcopyWithを使って更新しているという流れになります。

ちょっと複雑になってるので何回か見直して整理してみてくださいね。



これでstateにrecordと言う名前で今日の体重の情報を保存できるようになりました。

ちゃんとページの方で登録ボタンを押したらregisterメソッドが発動するようにしておきましょう👍

onTap: () {
  notifier.register();
  Navigator.pop(context);
},

ちなみにこちらは

 Navigator.pop(context);

簡単に言うと戻るボタンです。
1段階戻ります。

今ポップアップが出ている状態かと思いますので、このポップアップを登録ボタンを押したら閉じるようにしないと不自然です。
そのためメソッドの最後にこれを置いています。

多分今後も色々なところで活用します。


ListView


次は表示の部分になります!

今現在でstate.rocordには配列で日々の記録が格納されています。
それを記録がある分だけ表示させる必要があります!

こう言う時PHPではforとかforeachとか使うことが多かったと思いますが、
 Flutterではどう言うのを使うでしょうか?

はい、ちゃんと便利なメソッドが準備されています。
ListView.builderです。
正確にはListViewウィジェットですがその中でも表示数が固定ではない場合はListView.builderを使います。
もし固定であればListViewで良いのですが、今回の記録数は使うユーザーによってもちろん変化がありますので、ListView.builderで実装することになります。

早速作ってみます!
まずはコードから。
たくさんコードありますが新しい部分は一部分です。
少しだけデザインも整えています。

いきなり全部実装すると混乱するのでまずはさっきまで表示させていた100kgのやつを5つ表示することにしています。

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final notifier = ref.watch(myPageProvider.notifier);
    debugPrint('描画');
    return Scaffold(
      appBar: AppBar(
        title: const Text('日々の体重を追加していくアプリ'),
      ),
      body: SingleChildScrollView(
        child: Center(
          child: Column(
            children: [
              SizedBox(
                height: MediaQuery.of(context).size.height - 200,
                child: ListView.builder(
                    itemCount: 5,
                    itemBuilder: (BuildContext context, int index) {
                      return Container(
                        height: 100,
                        margin: const EdgeInsets.symmetric(
                          horizontal: 12,
                          vertical: 26,
                        ),
                        decoration: BoxDecoration(
                          color: Colors.white,
                          boxShadow: const [
                            BoxShadow(
                              color: Colors.black26,
                              spreadRadius: 1,
                              blurRadius: 10,
                              offset: Offset(10, 10),
                            ),
                          ],
                          border: Border.all(color: Colors.black),
                          borderRadius: BorderRadius.circular(10),
                        ),
                        child: Row(
                          children: [
                            Builder(
                              builder: (BuildContext context) {
                                final count = context
                                    .select((MyPageState state) => state.count);
                                return Container(
                                  padding: const EdgeInsets.only(left: 12),
                                  width: 100,
                                  child: Text(
                                    count.toString(),
                                    style: const TextStyle(
                                        fontWeight: FontWeight.bold,
                                        fontSize: 30),
                                  ),
                                );
                              },
                            ),
                            const SizedBox(
                              width: 10,
                            ),
                            Expanded(
                              child: Column(
                                mainAxisAlignment: MainAxisAlignment.center,
                                crossAxisAlignment: CrossAxisAlignment.start,
                                children: [
                                  Padding(
                                    padding:
                                        const EdgeInsets.symmetric(vertical: 4),
                                    child: Row(
                                      children: const [
                                        SizedBox(
                                          width: 24,
                                          child: Icon(Icons.calendar_today),
                                        ),
                                        SizedBox(
                                          width: 8,
                                        ),
                                        Text(
                                          '2020/10/16',
                                          style: TextStyle(
                                            fontSize: 12,
                                          ),
                                        ),
                                      ],
                                    ),
                                  ),
                                  Row(
                                    children: const [
                                      SizedBox(
                                        width: 24,
                                        child: Icon(Icons.comment),
                                      ),
                                      SizedBox(
                                        width: 8,
                                      ),
                                      Text(
                                        'これは、、、やっちまった、、、',
                                        style: TextStyle(
                                          fontSize: 12,
                                        ),
                                      ),
                                    ],
                                  ),
                                ],
                              ),
                            ),
                          ],
                        ),
                      );
                    }),
              ),
              Container(
                height: 200,
                width: MediaQuery.of(context).size.width,
                padding: const EdgeInsets.symmetric(vertical: 12),
                color: Colors.blue,
                child: Column(
                  children: [
                    const Text(
                      '今日の体重を追加しよう',
                      style: TextStyle(color: Colors.white),
                    ),
                    IconButton(
                      icon: const Icon(
                        Icons.add_circle,
                        color: Colors.white,
                      ),
                      onPressed: () {
                        popUpForm(context, notifier);
                      },
                    )
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

SingleChildScrollViewはスクロールを可能とするWidgetです。
詳しい解説は画面の記事で書いているのでそっち見て下さいね!

次は、
height: MediaQuery.of(context).size.height - 200,
です。

MediaQuery.of(context).sizeで今アプリを見ているデバイスのサイズを取得することが出来ます。
さらに、

MediaQuery.of(context).size.heightで高さ
MediaQuery.of(context).size.widthで横幅

を取得できます。

これを使うことで200とかの固定値を使うのではなく、
デバイスに合わして変動するサイズにすることが出来るので自分は多用しています。

上記ではデバイスの高さから200を引いた領域をSizedBoxで確保しています。

次に

ListView.builder()ですね。
最初はとっつきにくそうな感じなんですが
実はこの人自体はそんなに難しいことはしていません。
あとでStateの情報を利用する場合にちょっとややこしくなります。

まず、
itemCount: 5,
はそのままの意味で表示を5回繰り返すことになります。

そして何を5回繰り返すのかと言えば
itemBuilder: (BuildContext context, int index) {
  return 繰り返すWidget
}

ですね。

上記では先ほどの100kgのやつをそこに入れることで5回繰り返すことに成功しています。

画面としてはこうなっているかと思います!




ちなみにListViewは横のスクロールなども実現できますのでやりたい時は調べてみて下さいね。

さて、ここまで来たら後は、
①itemCountの数をStateに保存されているreportの数にする
②表示させている部分(100Kgとか)をStateの情報に置き換える必要が出てきます。

ぜひ自分で行けるかも!!という方はここで自分で頑張って見ましょう!
変数に置き換える過程でconstのエラーが出ますのでご注意を、、、。

~~

それではとりあえずコードです!
(ちょっと変な部分あるけど気付いた人は学習用だと思ってスルーで)


  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final notifier = ref.watch(myPageProvider.notifier);
    debugPrint('描画');
    return Scaffold(
      appBar: AppBar(
        title: const Text('日々の体重を追加していくアプリ'),
      ),
      body: SingleChildScrollView(
        child: Center(
          child: Column(
            children: [
              SizedBox(
                height: MediaQuery.of(context).size.height - 200,
                     child: Consumer(builder: (context, ref, child) {
                  final records =
                      ref.watch(myPageProvider.select((value) => value.record));
                  return ListView.builder(
                      itemCount: records.length,
                      itemBuilder: (BuildContext context, int index) {
                        final record = records[index];
                        return Container(
                          height: 100,
                          margin: const EdgeInsets.symmetric(
                            horizontal: 12,
                            vertical: 26,
                          ),
                          decoration: BoxDecoration(
                            color: Colors.white,
                            boxShadow: const [
                              BoxShadow(
                                color: Colors.black26,
                                spreadRadius: 1,
                                blurRadius: 10,
                                offset: Offset(10, 10),
                              ),
                            ],
                            border: Border.all(color: Colors.black),
                            borderRadius: BorderRadius.circular(10),
                          ),
                          child: Row(
                            children: [
                              Builder(
                                builder: (BuildContext context) {
                                  return Container(
                                    padding: const EdgeInsets.only(left: 12),
                                    width: 100,
                                    child: Text(
                                      record['weight']!,
                                      style: const TextStyle(
                                          fontWeight: FontWeight.bold,
                                          fontSize: 30),
                                    ),
                                  );
                                },
                              ),
                              const SizedBox(
                                width: 10,
                              ),
                              Expanded(
                                child: Column(
                                  mainAxisAlignment: MainAxisAlignment.center,
                                  crossAxisAlignment: CrossAxisAlignment.start,
                                  children: [
                                    Padding(
                                      padding: const EdgeInsets.symmetric(
                                          vertical: 4),
                                      child: Row(
                                        children: [
                                          const SizedBox(
                                            width: 24,
                                            child: Icon(Icons.calendar_today),
                                          ),
                                          const SizedBox(
                                            width: 8,
                                          ),
                                          Text(
                                            record['day']!,
                                            style: const TextStyle(
                                              fontSize: 12,
                                            ),
                                          ),
                                        ],
                                      ),
                                    ),
                                    Row(
                                      children: [
                                        const SizedBox(
                                          width: 24,
                                          child: Icon(Icons.comment),
                                        ),
                                        const SizedBox(
                                          width: 8,
                                        ),
                                        Text(
                                          record['comment']!,
                                          style: const TextStyle(
                                            fontSize: 12,
                                          ),
                                        ),
                                      ],
                                    ),
                                  ],
                                ),
                              ),
                            ],
                          ),
                        );
                      });
                }),
              ),
              Container(
                height: 200,
                width: MediaQuery.of(context).size.width,
                padding: const EdgeInsets.symmetric(vertical: 12),
                color: Colors.blue,
                child: Column(
                  children: [
                    const Text(
                      '今日の体重を追加しよう',
                      style: TextStyle(color: Colors.white),
                    ),
                    IconButton(
                      icon: const Icon(
                        Icons.add_circle,
                        color: Colors.white,
                      ),
                      onPressed: () {
                        popUpForm(context, notifier);
                      },
                    )
                  ],
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }

順に説明していきます!
まずは先ほどもやったstate情報を取得するところです。

 child: Consumer(
    builder: (context, ref, child) {
      final records = context.select((MyPageState state) => state.record);
 
 〜略〜

ここは二度目だから分かると思いますので確認程度で!
ちなみに先ほどのカウントアップする部分はもう不要なので削除しています。

次は
itemCount: records.length,
です。
recordsは配列なので、lengthで中に入っている個数を獲得できます。
なので自動的に個数の数字に置き換わってくれるので記録の分だけListViewが情報を表示してくれるようになります。

次は
records[index]['weight'],
records[index]['day'],
records[index]['comment'],
とかの部分です。
(※ちなみに連想配列より型にしちゃうのがいいと思いますがこの記事では割愛してます)

 indexについてはListViewのここを見て欲しいのですが、

itemBuilder: (BuildContext context, int index) {

第二引数で取得しています。
自動的にListViewが今何番目の回転なのかの情報をindexと言う変数で与えてくれてるんですね。
素晴らしい。

これを利用することで1番目の情報の~~までは分かりますので、あとは他の言語で連想配列を取り出すのと同じように、欲しい情報の名前を指定してあげれば情報が取得できます。

+ボタンで追加されるたびに情報が表示されていくと思います!

良いですね!良いですね!
今はビルドし直すとこの情報は消えてしまいますがfirebaseやデバイスとかにこの情報を保存しておけば立派な体重管理アプリが誕生しそうですね!

さてただ表示の部分でちょっと味気ない部分があるので調整していきます。

文字列と変数の結合


まず体重の部分。
せっかくならKgを表示させたい!

つまり変数+文字列をする必要がありますが書き方は、

child: Text(
 '${records[index]['weight']}Kg',
 style: TextStyle(
     fontWeight: FontWeight.bold,
     fontSize: 30),
),

です!

'${変数}文字列'

という形になっています!
''で囲んだ上で、変数は${}で囲む感じです!これなら何個でも変数を挟んだりできます!

次は日付の部分!
ちょっとそのまま出過ぎてる感ありますんで修正します。
(パッケージをを使えばその機能を使って表示も可能ですが今回は文字列結合の練習ということで。。。)

登録の時点なのでmy_page_notifier.dartの
register()の中ですね。

今現在日付の情報はこんな感じで取得していますが、
2023年11月11日のような記載にしたいですよね!

ということで年月を自分で生成してみます。
せっかくなのでさっきの変数と文字列を結合する復習にしちゃいましょう。

   final dateTime = DateTime.now();
   final day = '${dateTime.year}年${dateTime.month}月${dateTime.day}日';
   final formRecord = {
     'weight': state.weight,
     'comment': state.comment,
     'day': day,
   };
 

DateTime.now()はyearやmonthで年月をとることが出来ます。
なのでこの書き方で年月日を結合させて表示させることにしました!

実際に見てみましょう!
ビルドし直して情報を初期化してから再度情報を入力してみましょう!

良い感じですね!

Widgetの切り出し


これで完成、、、、
と言いたいところですが、最後にWidgetの切り出しを行いたいと思います。

画面側のCard部分
(↓これ)


をWidgetとしてどこでも共通で使えるように切り出してみましょう!
ちょっとこのUIは汎用性がないかもですが共通化できるようなボタンや装飾はどんどん別ファイルに切り出していくと重複した実装をせずに済みます。
切り出すWidgetの名前はWeightCardにでもしてみましょう!

せっかくなのでlib/widgetsというフォルダを作ってそこに記載をします!
weight_card.dartという名前にでもして見ましょう!


答えです!
特に状態変化をさせるわけではない表示の部分なのでConsumerWidgetを引き継ぐ必要はありませんよ!

import 'package:flutter/material.dart';

class WeightCard extends StatelessWidget {
  const WeightCard(this.record, {super.key});

  final Map<String,String> record;

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 100,
      margin: const EdgeInsets.symmetric(
        horizontal: 12,
        vertical: 26,
      ),
      decoration: BoxDecoration(
        color: Colors.white,
        boxShadow: const [
          BoxShadow(
            color: Colors.black26,
            spreadRadius: 1,
            blurRadius: 10,
            offset: Offset(10, 10),
          ),
        ],
        border: Border.all(color: Colors.black),
        borderRadius: BorderRadius.circular(10),
      ),
      child: Row(
        children: [
          Builder(
            builder: (BuildContext context) {
              return Container(
                padding: const EdgeInsets.only(left: 12),
                width: 100,
                child: Text(
                  '${record['weight']}Kg',
                  style: const TextStyle(
                      fontWeight: FontWeight.bold, fontSize: 30),
                ),
              );
            },
          ),
          const SizedBox(
            width: 10,
          ),
          Expanded(
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Padding(
                  padding: const EdgeInsets.symmetric(vertical: 4),
                  child: Row(
                    children: [
                      const SizedBox(
                        width: 24,
                        child: Icon(Icons.calendar_today),
                      ),
                      const SizedBox(
                        width: 8,
                      ),
                      Text(
                        record['day']!,
                        style: const TextStyle(
                          fontSize: 12,
                        ),
                      ),
                    ],
                  ),
                ),
                Row(
                  children: [
                    const SizedBox(
                      width: 24,
                      child: Icon(Icons.comment),
                    ),
                    const SizedBox(
                      width: 8,
                    ),
                    Text(
                      record['comment']!,
                      style: const TextStyle(
                        fontSize: 12,
                      ),
                    ),
                  ],
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

呼び出してる方、my_page.dart
※importも忘れずに!

~略~
    return Scaffold(
     appBar: AppBar(
       title: Text('日々の体重を追加していくアプリ'),
     ),
     body: SingleChildScrollView(
       child: Center(
         child: Column(
           children: [
             SizedBox(
               height: MediaQuery.of(context).size.height - 200,
                                  child: Consumer(builder: (context, ref, child) {
                  final records =
                      ref.watch(myPageProvider.select((value) => value.record));
                  return ListView.builder(
                     itemCount: records.length,
                     itemBuilder: (BuildContext context, int index) {
                       return WeightCard(record);
                     },
                   );
                 },
               ),
             ),
 ~略~



出来ましたね!

切り出し方はしっかり覚えてて下さい。

ということで今度は新たにcommon_button.dartを作って「登録」のボタンを切り出してみて下さい!!
文字やタップした時の動作なども引数で渡してあげれたら色々なところで利用できそうですね!
今回は答えはありません☺️

最後に

実装お疲れ様でした!
もしここまでちゃんとこなせたならとても才能アリアリだと思います!
素晴らしい!
ぜひぜひTwitterとかで作り終えた!!
とか報告してくれたら嬉しいです!!☺️

Twitter

なんか関西のエンジニアのコミュニティも運営してるんで完成の人はフォローもしてね!

追加実践課題

最後に鬼の実践課題を放り投げていきたいと思います!
もうここに書くのは解説の予定はありません!
自分の力で解いてみて下さい!


各カードに編集と削除のボタンを作成し、その機能を実装して下さい。


ではでは!!

いいなと思ったら応援しよう!