Flutter3系+RiverPod+StateNotifier+freezedで日々の記録管理アプリを作ろう!

はじめに

※こちら執筆途中の記事です。

初めまして、大阪で合同会社MolasoftっていうFlutterやLaravel、Nuxtでの開発を得意としたシステム会社を運営している向江と言います!
今回の記事では、今やモバイル開発の主流にもなりつつある「Flutter」について、実践型の記事を書いてみようと思います!

なおこの記事を利用して、
9/10(土)13:00~大阪/日本橋にてハンズオンを開催予定です!この記事で詳しく説明していない部分や皆様の質問も受け付けたいと思うので、
ぜひぜひご参加ください😊

前提条件

この記事を実施するにあたって、flutter3系にてデフォルトアプリがビルドできていることが最低限の条件となります。
それ以外は分からなくてもなんとかなるでしょう!笑

作るアプリの概要

  • RiverpodとStateNotifierとfreezedで状態管理

  • 日々の記録はデバイス保存(sharedPrefarence)

  • カレンダーで日付を選択

  • 変な入力したらボタン非活性&トースト表示

Slack入っとく?

ハンズオンの時用のSlackがありますんで、みんなからの質問や苦しんでる状況が確認できるのでこの記事をやり抜くモチベにもなるかもです。
次のイベントの情報とかも流すと思うので誰でもお気軽にどうぞ!!


それでは作成開始!

まずは事前準備。

実際に実装を開始する前に以下の環境ができてるのをご確認ください!
※本記事ではここらへんには触れないので、たくさんある他の記事を参考にしてください!
※本記事ではAndroidStudioを利用しています。

①Xcodeがインストールされている
②Flutter3系がインストールされている(fvmやasdfでもOK)
③エディター(AndroidStudioやVsCode)がインストールされている
④Javaがインストールされている
⑤その他flutter doctorをした場合にエラーが表示されない
⑥デフォルトアプリがシュミレーターで表示できている

要件の整理

- ユーザーは初期画面で自分の記録の履歴一覧を見ることが出来る
- ユーザーは初期画面にある追加ボタンから「日付、トレーニングメニュー、内容」を記入して登録することができる。
- 記録はデバイスに保存される。

とてもシンプルですね。

フォルダやファイル構成を考える

要件も決まったことだし早速実装に、、、と思いますがまずは構成について考えましょう。
家を建てるにも、車を作るにも、設計図や設計書が必要なようにシステムにも設計が当然必要です!

本記事では機能が簡単なので設計とまでは言いませんが簡単な構成は考えておきましょう!

こんな構成でやってみようと思います。

lib - presentation  //画面自体+画面に関わる機能
    - domain //共通機能+Entity
    - infrastructure //外部への接続(アプリからデバイスへ接続など)
    - repository //DBへのアクセスなど。今回は使わない
    - di_provider.dart //後述
    - main.dart //Flutterだと最初にこれが走る

モバイルアプリの開発ではMVVMとかクリーンアーキテクチャーとか色々な設計がありますが、今回の記事では上記のような構成を取りたいと思います。
別にこの設計で無理にやる必要もないので、お好きな設計でも大丈夫です!
ちなみに雑談ですが設計は「絶対にこれが正しい!」というのがいい意味でも悪い意味でも存在しないと個人的には思っています。
(多分自分のもある人から見たらなんで?ってなると思う)

プロジェクトの規模感によってはMVVMでは責務の切り分けが難しそうとか、クリーンアーキテクチャーだとエンジニアの熟練度が足りなくてぐちゃぐちゃになっちゃったとかあるあるだと思います。
- それぞれのプロジェクトの規模感
- どういったエンジニアがjoinするのか
- 納期重視なのか
- 追加開発が頻繁に行われる予定なのか
などなどそれぞれの状況で決めていいと思っています。
ただ1つチーム全体でどういう設計でいくのかを固めて認識を「深く」合わせておくことだけは必須です!(意外とやってないとこあるんだなぁ。。)

話がそれましたが初心者の方で何言ってんだこいつって思った方は、
3人の大工さんで1つの家を建てるときに、マンション建てる方法、木造を建てる方法などバラバラで建てた家の危険性は想像しやすいと思いますのでそれくらいの認識で。
大工さん(プログラミング)も大事だけど建築士(設計)も大事だよなくらいの認識で。

画面を作成しよう

一覧画面の実装

以下の画像のデザインを見て試しに実装してみましょう!!


presentation層にtop_pageというフォルダを作ってtop_page.dartを作成の上、StatelessWidget(必須)で作り込んでみましょう!
ちなみにAndroidStudioならstlって打ったら自動でStatelessWidgetの型を表示させてくれますよ!


ちなみに、ここだけは統一しておきたいのですがmain.dartを改修してからこんな感じでTopPageを呼び出してください!

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'demo',
      theme: ThemeData(
        primarySwatch: Colors.blueGrey,
      ),
      home: const TopPage(),
    );
  }



ListView.builder
Card
ListTile
のWidgetを使っています!
まだ日付とかは固定の値でOKです!
個数はとりあえず10個にしておきましょう!!

※分からない方は下の回答をチラチラみながら頑張ってみてください。

~15分くらいかな?~

まずフォルダ構成はこういうことになります。

続きましてコード!
※import周りは除外してます。
※importはAndroidStudioなら赤波線をホバーさせてimportってするだけ!エディターって偉大!


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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text(
          '記録一覧',
          style: TextStyle(
            fontWeight: FontWeight.w700,
            fontSize: 16,
          ),
        ),
      ),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.center,
        children: [
          SizedBox(
            width: MediaQuery.of(context).size.width,
            height: MediaQuery.of(context).size.height * 0.86,
            child: ListView.builder(
                itemCount: 10,
                itemBuilder: (BuildContext context, int index) {
                  return Center(
                    child: Card(
                      child: ListTile(
                        title: Text('タイトル'),
                        subtitle: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Text('内容'),
                            Text('2022/2/22'),
                          ],
                        ),
                        trailing: const Icon(Icons.accessibility_new_rounded),
                      ),
                    ),
                  );
                },
             ),
          )
        ],
      ),
    );
  }
}


追加ボタンを設置しましょう!
まずはこちらを無心でscaffold直下に貼ってみましょう!

      floatingActionButton: FloatingActionButton(
        onPressed: () {
           print('押された');
        },
        backgroundColor: Colors.black,
        child: const Icon(Icons.add),
      ),

一瞬でボタンを追加できましたね。
押すとコンソール上で「押された」って表示できることが確認できると思います。
Flutterの大きな魅力としてたった数行で簡単にこういったUIの実装ができることがあります。
もちろんもう少し凝ったボタンにもできます。
余裕がある方は「floatingActionButton デザイン」とか調べて装飾してみましょう!

初心者向け:インデント忘れないで

上のコードを見て、文字がやたら右に寄っちゃってるな、
左につーめよ。って方は日本語的には最高ですが、プログラミングの切開ではNGです。
よくみると、{}とか[]の最初と最後の位置が一致するようになってるんですね。
なので、このColumnっていうWidgetはここまでなのね。とかが一発でわかることになります。
そんなん自分が実装してるから解りますやん!って方は、まあ上司かチームメンバーか、1人開発でも半年後に色々忘れた自分に◯されるんで、このまま行っても大丈夫です!笑
この理論で、final valueとかfinal a とかぱっと見で分からない変数名はつけちゃだめですからね。。。(※一時的な部分は別にいいと思っている)


いざ!Riverpod!

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

状態管理とは?

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

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

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

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

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

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

パッケージとは?

便利なやつ!!(追記予定)
落し方
①公式HPのinstallsをクリックしてコピー!
②pubspec.yamlに追記!
③pub get
④readmeを見て実装!完!
後でカレンダーを表示させるやつでパッケージの偉大さを教えます!

flutter_riverpodをインストール!

全体的に追記予定()

まずは
main.dartでこう!

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

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

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

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

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

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

…。

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

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

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

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

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

StateNotifierをインストール

まずは機能面を書くことができるStateNotifierをインストールしましょう!
と思いましたがRiverpodと仲が良すぎてRiverpodのパッケージに内包されていました。(それだけ一緒に使ってねってこと)
早速使ってみましょう!
top_pageフォルダの中にtop_page_notifier.dartを作成して
公式のサンプルと同じコードを書いちゃいます!

import 'package:flutter_riverpod/flutter_riverpod.dart';

class TopPageNotifier extends StateNotifier<int> {
  TopPageNotifier(): super(0);

  void increment() => state++;
}

まだfreezedを使っていないのでstateNotifier自体が数字の0だけを持っている状態ですね。
ちなみに、上の StateNotifier<int>ってところに StateNotifier<自作クラス()>とかしたらたくさん値を管理できるようになるのですが、、、。
freezedを使わないととっても面倒な事になります。
軽く後で触れますね。

まずはとりあえずRiverpodとStateNotifierの挙動を試していきましょう!

イラストで言うとこの状態になりました!

このStateNotifierをTopPageで使えるようになりたいので、
こういう記述になります!

final topPageProvider =
    StateNotifierProvider<TopPageNotifier, int>((ref) {
  return TopPageNotifier();
});

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

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

TopPageクラスの外で定義してあげています。
書き方はまんまsampleを使っています。
つまり使い方のお約束なので頑張って覚えましょう。

さて、こうするとtop_pageにTopPageNotifierとintのstateが開通して使えるようになりました。

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

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

final state = ref.watch(topPageProvider);
//stateには0が入っている

ちなみに
ref.read()も


final state = ref.read(topPageProvider)
//stateには0が入っている

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

ちなみにnotifier自体は、

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

こんな感じで取れます。

それでは一旦top_page.dartをこの状態にします!

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hands_on/presentation/top_page/top_page_notifier.dart';

final topPageProvider = StateNotifierProvider<TopPageNotifier, int>((ref) {
  return TopPageNotifier();
});

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final topPageState = ref.watch(topPageProvider);
    final topPageNotifier = ref.read(topPageProvider.notifier);
    return Scaffold(
      appBar: AppBar(
        title: const Text(
          '記録一覧',
          style: TextStyle(
            fontWeight: FontWeight.w700,
            fontSize: 16,
          ),
        ),
      ),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.center,
        children: [
          SizedBox(
            width: MediaQuery.of(context).size.width,
            height: MediaQuery.of(context).size.height * 0.86,
            child: ListView.builder(
                itemCount: topPageState,
                itemBuilder: (BuildContext context, int index) {
                  return Center(
                    child: Card(
                      child: ListTile(
                        ///型を作らないとこんなことになっちゃう
                        title: Text('タイトル'),
                        subtitle: Column(
                          crossAxisAlignment: CrossAxisAlignment.start,
                          children: [
                            Text('内容'),
                            Text('日付'),
                          ],
                        ),
                        trailing: const Icon(Icons.accessibility_new_rounded),
                      ),
                    ),
                  );
                }),
          )
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          topPageNotifier.increment();
        },
        backgroundColor: Colors.black,
        child: const Icon(Icons.add),
      ),
    );
  }
}

やっていることは、ListView.builderの個数にstate情報を使うようにしたのと、プラスボタンを押したら数字が1増えるようにしています!

どうでしょう!押せば押すほど増えていきますね!

さてここで、
ref.watch()
ref.read()
の違いを。
試しに、
final topPagestate = ref.watch(topPageProvider);

final topPagestate = ref.read(topPageProvider);
に変えて再度ビルドしてみしょう。

あれ、更新されなくなりましたね!
これが違いです!
watchは監視!👀
readは読み込むのみ!


freezedを利用できるようになろう




追加ボタンからの遷移と追加ページを実装

RegisterPage周りのフォルダ構成
※まだNotifierやfreezedは不要です。


画面遷移の方法!!
top_pageのfloatingActionButtonの中身をこれに変えました!

      floatingActionButton: FloatingActionButton(
        onPressed: () {
          Navigator.of(context, rootNavigator: true).push<void>(
            CupertinoPageRoute(
              builder: (_) => const RegisterPage(),
            ),
          );
        },
        backgroundColor: Colors.black,
        child: const Icon(Icons.add),
      ),


register_page自体!

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hands_on/presentation/register_page/register_page_notifier.dart';
import 'package:table_calendar/table_calendar.dart';

final registerPageProvider =
    StateNotifierProvider<RegisterPageNotifier, RegisterPageState>((ref) {
  return RegisterPageNotifier(ref);
});

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final registerPageNotifier = ref.read(registerPageProvider.notifier);
    final registerPageState = ref.watch(registerPageProvider);
    return Scaffold(
      appBar: AppBar(
        title: const Text(
          '記録を追加',
          style: TextStyle(
            fontWeight: FontWeight.w700,
            fontSize: 16,
          ),
        ),
        actions: [
          InkWell(
            onTap: () async {
              await registerPageNotifier.save();
              Navigator.pop(context);
            },
            child: const Padding(
              padding: EdgeInsets.only(top: 20, right: 8),
              child: Text(
                '保存',
                style: TextStyle(
                    fontSize: 14,
                    fontWeight: FontWeight.w700,
                    color: Colors.lightBlueAccent),
              ),
            ),
          )
        ],
      ),
      body: Center(
          child: Column(
        mainAxisAlignment: MainAxisAlignment.start,
        children: [
          const SizedBox(
            height: 20,
          ),
          TableCalendar(
            firstDay: DateTime.utc(2010, 10, 16),
            lastDay: DateTime.utc(2030, 3, 14),

            /// このままだと別の月を選択すると、、、。
            focusedDay: DateTime.now(),
            selectedDayPredicate: (day) {
              return isSameDay(registerPageState.selectedDay, day);
            },
            onDaySelected: (selectedDay, focusedDay) async {
              await registerPageNotifier.selectDay(selectedDay);

              ///追加課題
            },
          ),
          const SizedBox(
            height: 20,
          ),
          Padding(
            padding: const EdgeInsets.all(12),
            child: TextFormField(
              autofocus: true,
              decoration: InputDecoration(
                labelText: 'タイトル',
                labelStyle: const TextStyle(color: Colors.black26),
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(20),
                ),
              ),
              onChanged: (value) {
                registerPageNotifier.setTitle(value);
              },
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(12),
            child: TextFormField(
              autofocus: true,
              maxLines: 4,
              decoration: InputDecoration(
                labelText: '内容',
                labelStyle: const TextStyle(color: Colors.black26),
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(20),
                ),
              ),
              onChanged: (value) {
                registerPageNotifier.setContent(value);
              },
            ),
          ),
        ],
      )),
    );
  }
}

カレンダー


NotifierとFreezedをそろそろ作りましょう!!
管理したい値は、
- カレンダーが返す日付
- タイトル情報
- 内容
です。

これをfreezedで作成する場合、register_page_notifierにこのようなコードを書きます!

import 'package:freezed_annotation/freezed_annotation.dart';
part 'register_page_notifier.freezed.dart';

@freezed
class RegisterPageState with _$RegisterPageState {
  factory RegisterPageState({
    DateTime? selectedDay,
    String? title,
    String? content,
  }) = _RegisterPageState;
}


class RegisterPageNotifier extends StateNotifier<RegisterPageState> {
  RegisterPageNotifier() : super(RegisterPageState());
}

そしてビルドランナー!!

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

自動でfreezedファイルができたと思います!
freezedを見てみましょう!

RegisterPageでもRiverpodを導入してみましょう!

TopPageのやり方を真似してみましょう!👍
freezedのstateを更新する方法は、

state = state.copyWith(~~: 値);

です!

RegisterPageNotifierにそれぞれ、
日付の選択
タイトルの保存
内容の保存
の関数を実装しましょう!

例 日付の保存

class RegisterPageNotifier extends StateNotifier<RegisterPageState> {
  RegisterPageNotifier() : super(RegisterPageState());

  Future<void> selectDay(DateTime selectedDay) async {
    state = state.copyWith(selectedDay: selectedDay);
  }
}


保存機能の実装!

saveメソッドを作りましょう!
保存先はsharedPrefarenceです!

SharedPrefarenceで端末への保存と参照


完成コード


sharedPreferencre




import 'dart:convert';

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

class SharedPreference {
  const SharedPreference({Key? key}) : super();

  Future<List<String>> getJsonRecords() async {
    final prefs = await SharedPreferences.getInstance();
    final recordsString = prefs.getStringList('records') ?? [];
    return recordsString;
  }

  Future<List<Object>> getRecord() async {
    final jsonRecords = await getJsonRecords();
    final records = jsonRecords.map((e) => jsonDecode(e) as Object);
    return records.toList();
  }

  Future<void> saveRecord(
      String menu, String content, DateTime dateTime) async {
    dateTime.toString();
    final prefs = await SharedPreferences.getInstance();
    final currentRecords = await getJsonRecords();
    currentRecords.add(jsonEncode({
      'menu': menu,
      'content': content,
      'dateTime': dateTime.toString(),
    }));
    print(currentRecords);
    prefs.setStringList('records', currentRecords);
  }

  Future<void> resetRecords() async {
    final prefs = await SharedPreferences.getInstance();
    prefs.remove('records');
  }
}


register_page.dart

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hands_on/presentation/register_page/register_page_notifier.dart';
import 'package:table_calendar/table_calendar.dart';

final registerPageProvider =
    StateNotifierProvider<RegisterPageNotifier, RegisterPageState>((ref) {
  return RegisterPageNotifier(ref);
});

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final registerPageNotifier = ref.read(registerPageProvider.notifier);
    final registerPageState = ref.watch(registerPageProvider);
    return Scaffold(
      appBar: AppBar(
        title: const Text(
          '記録を追加',
          style: TextStyle(
            fontWeight: FontWeight.w700,
            fontSize: 16,
          ),
        ),
        actions: [
          InkWell(
            onTap: () async {
              await registerPageNotifier.save();
              Navigator.pop(context);
            },
            child: const Padding(
              padding: EdgeInsets.only(top: 20, right: 8),
              child: Text(
                '保存',
                style: TextStyle(
                    fontSize: 14,
                    fontWeight: FontWeight.w700,
                    color: Colors.lightBlueAccent),
              ),
            ),
          )
        ],
      ),
      body: Center(
          child: Column(
        mainAxisAlignment: MainAxisAlignment.start,
        children: [
          const SizedBox(
            height: 20,
          ),
          TableCalendar(
            firstDay: DateTime.utc(2010, 10, 16),
            lastDay: DateTime.utc(2030, 3, 14),

            /// このままだと別の月を選択すると、、、。
            focusedDay: DateTime.now(),
            selectedDayPredicate: (day) {
              return isSameDay(registerPageState.selectedDay, day);
            },
            onDaySelected: (selectedDay, focusedDay) async {
              await registerPageNotifier.selectDay(selectedDay);

              ///追加課題
            },
          ),
          const SizedBox(
            height: 20,
          ),
          Padding(
            padding: const EdgeInsets.all(12),
            child: TextFormField(
              autofocus: true,
              decoration: InputDecoration(
                labelText: 'タイトル',
                labelStyle: const TextStyle(color: Colors.black26),
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(20),
                ),
              ),
              onChanged: (value) {
                registerPageNotifier.setTitle(value);
              },
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(12),
            child: TextFormField(
              autofocus: true,
              maxLines: 4,
              decoration: InputDecoration(
                labelText: '内容',
                labelStyle: const TextStyle(color: Colors.black26),
                border: OutlineInputBorder(
                  borderRadius: BorderRadius.circular(20),
                ),
              ),
              onChanged: (value) {
                registerPageNotifier.setContent(value);
              },
            ),
          ),
        ],
      )),
    );
  }
}


register_page_notifierの完成系!

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:hands_on/infrastracture/sharedPreference.dart';

import '../top_page/top_page.dart';

part 'register_page_notifier.freezed.dart';

@freezed
class RegisterPageState with _$RegisterPageState {
  factory RegisterPageState({
    DateTime? dateTime,
    DateTime? selectedDay,
    String? title,
    String? content,
    @Default(false) bool posting,
  }) = _RegisterPageState;
}

final _sharedPreference = const SharedPreference();

class RegisterPageNotifier extends StateNotifier<RegisterPageState> {
  RegisterPageNotifier(this.ref) : super(RegisterPageState());

  final Ref ref;

  void printing() {
    print(state);
  }

  Future<void> selectDay(DateTime selectedDay) async {
    state = state.copyWith(selectedDay: selectedDay);
  }

  Future<void> save() async {
    await _sharedPreference.saveRecord(
      state.title ?? '',
      state.content ?? '',
      state.selectedDay ?? DateTime.now(),
    );
    Fluttertoast.showToast(
      msg: "記録が保存されました。",
      toastLength: Toast.LENGTH_LONG,
      gravity: ToastGravity.BOTTOM,
      timeInSecForIosWeb: 1,
      backgroundColor: Colors.black,
      textColor: Colors.white,
      fontSize: 16.0,
    );

    ///topPageにあるproviderを利用してみる
    ///Providerだとこんなことできなかった。違うインスタンス扱いになっていた
    final topPageNotifier = ref.read(topPageProvider.notifier);
    await topPageNotifier.initState();
  }

  void setTitle(String value) {
    state = state.copyWith(title: value);
  }

  void setContent(String value) {
    state = state.copyWith(content: value);
  }
}


top_pageの最終系!

import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:hands_on/presentation/top_page/top_page_notifier.dart';

import '../../di_provider.dart';
import '../register_page/register_page.dart';

final topPageProvider =
    StateNotifierProvider<TopPageNotifier, TopPageState>((ref) {
  return TopPageNotifier();
});

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    ///state全体をとる watchは値の変更を監視し再ビルドする
    final topPageState = ref.watch(topPageProvider);

    ///特定のstateだけをとるやり方
    final recordList =
        ref.watch(topPageProvider.select((value) => value.recordList)) ?? [];

    ///readは固定で値を参照するので変更があっても無視
    final topNotifier = ref.read(topProvider.notifier);
    final topPageNotifier = ref.read(topPageProvider.notifier);
    return Scaffold(
      appBar: AppBar(
        title: const Text(
          'メモ一覧',
          style: TextStyle(
            fontWeight: FontWeight.w700,
            fontSize: 16,
          ),
        ),
        actions: [
          InkWell(
            onTap: () async {
              await topPageNotifier.resetRecords();
            },
            child: const Padding(
              padding: EdgeInsets.only(top: 20, right: 8),
              child: Text(
                'リセット',
                style: TextStyle(
                    fontSize: 14,
                    fontWeight: FontWeight.w700,
                    color: Colors.lightBlueAccent),
              ),
            ),
          )
        ],
      ),
      body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        crossAxisAlignment: CrossAxisAlignment.center,
        children: [
          recordList.isNotEmpty
              ? SizedBox(
                  width: MediaQuery.of(context).size.width,
                  height: MediaQuery.of(context).size.height * 0.86,
                  child: ListView.builder(
                      itemCount: recordList.length,
                      itemBuilder: (BuildContext context, int index) {
                        ///型を作らないとこんなことになってしまう
                        final record =
                            recordList[index] as Map<String, dynamic>;
                        return Center(
                          child: Card(
                            child: ListTile(
                              ///型を作らないとこんなことになっちゃう
                              title: Text(record['menu'].toString()),
                              subtitle: Column(
                                crossAxisAlignment: CrossAxisAlignment.start,
                                children: [
                                  Text(record['content'].toString()),
                                  Text(record['dateTime'].toString()),
                                ],
                              ),
                              trailing:
                                  const Icon(Icons.accessibility_new_rounded),
                            ),
                          ),
                        );
                      }),
                )
              : Center(
                  child: Column(
                  children: const [
                    Icon(Icons.catching_pokemon),
                    Text('記録がありません'),
                  ],
                ))
          // InkWell(
          //     onTap: () {
          //       topPageNotifier.increment();
          //     },
          //     child: Text(topPageState.number.toString())),
        ],
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          Navigator.of(context, rootNavigator: true).push<void>(
            CupertinoPageRoute(
              builder: (_) => const RegisterPage(),
            ),
          );
        },
        backgroundColor: Colors.black,
        child: const Icon(Icons.add),
      ),
    );
  }
}


top_page_notifierの最終系

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

import '../../infrastracture/sharedPreference.dart';

part 'top_page_notifier.freezed.dart';

@freezed
class TopPageState with _$TopPageState {
  factory TopPageState({
    String? demo,
    @Default(0) int number,
    List<Object>? recordList,
  }) = _TopPageState;
}

class TopPageNotifier extends StateNotifier<TopPageState> {
  TopPageNotifier() : super(TopPageState()) {
    initState();
  }

  final _sharedPreference = const SharedPreference();

  Future<void> initState() async {
    final records = await _sharedPreference.getRecord();
    state = state.copyWith(recordList: records);
  }

  void increment() {
    state = state.copyWith(number: state.number + 1);
  }

  Future<void> resetRecords() async {
    state = state.copyWith(recordList: null);
    await _sharedPreference.resetRecords();
  }
}

途中課題:ユーザーの追加ボタンの連打を避けよう

今の状態で試しに保存ボタンを連打してみてください。
何回もトーストが出たと思います。
この状態では使い物になりません。
余裕がある方はまずは自分でどうにか対処してみましょう!

再度情報を取得しよう

追加処理は完了しましたが、このままでは一覧には表示されません。
デバイス保存した値を取得する処理はinitStateでしかやっていないからですね。再度取得しないといけないようです。

リセット機能を実装してみよう

情報がない場合は「記録がありません」と表示させてみよう

途中課題:バリデーションをしてみよう

追加課題

①編集機能を実装しよう
②個別の削除機能を実装しよう



この記事が気に入ったらサポートをしてみませんか?