MVVMをRiverpodで実装する

今回はユーザーが入力した生年月日をもとに年齢を割り出すという、
２画面の簡単なアプリをRiverpodを使って実装していく。
使用するライブラリは以下の通り。
```dart
dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.3.6
  freezed_annotation: ^2.4.1
  // 生年月日を入力するためのライブラリ
  flutter_datetime_picker_plus: ^2.0.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^2.0.0
  freezed: ^2.4.1
  build_runner: ^2.4.6
```
ディレクトリ構成は以下の通り。
![スクリーンショット 2023-07-20 0.07.57.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461823/0619a1d7-4ec5-f06b-f053-a11069fa863d.png)
完成はこんな感じ↓
![Screenrecorder-20230720-000919.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3461823/0f171010-58b8-15f4-1e90-4530ff59bcb5.gif)

## model
modelクラスを作成していく。
``Freezed``を使うことで簡単にimmutable(不変)なクラスを作ることができる。
コードを書いているといくつか必ずエラーが出るが、後のコマンドを回してファイルが自動生成されればエラーは消えるので無視でOK。
書き方についてはある程度お約束要素があるので、とりあえずこの形で覚えておく。
```dart:age_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';

//自動生成されるファイル名を指定する。
part 'age_state.freezed.dart';

@freezed
class AgeState with _$AgeState {
  factory AgeState({
    @Default(2023) int year,
    @Default(7) int month,
    @Default(19) int day,
    @Default(0) int age
  }) = _AgeState;
}
```
書けたらターミナルで以下のコマンドを回す。
```
flutter pub run build_runner watch --delete-conflicting-outputs
```
これで'age_state.freezed.dart'ファイルが生成された。
コマンドに--delete-conflicting-outputsをつけることでクラスに変更が加えられるたびに再生成されるらしいが、私はなぜかわからないがされなかったので手動でいちいち更新をかけていた。

## view_model
view_modelを作成していく。
StateNotifierを使用してviewで使いたいビジネスロジックをまとめる。
プロバイダ修飾子の``autoDispose``をつけることで参照されなくなったプロバイダのステート（状態）を自動で破棄してくれるようになる。
```dart:age_notifier.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mvvm_test/model/age_state.dart';

//AgeNotifierの状態を管理する
final ageProvider = 
    StateNotifierProvider.autoDispose<AgeNotifier, AgeState>(
  (ref) => AgeNotifier(),
);

class AgeNotifier extends StateNotifier<AgeState> {
  AgeNotifier(): super(AgeState());

  // 現在の日付と入力された生年月日から年齢を算出する
  void culculateAge() {
    state = state.copyWith(age: (((DateTime.now().year * 10000 + DateTime.now().month * 100 + DateTime.now().day) - (state.year * 10000 + state.month * 100 + state.day))/10000).floor());
  }

  // modelを入力された生年月日の日付に更新する
  void changeBirthday (int year, int month, int day) {
    state = state.copyWith(year: year, month: month, day: day);
  }
}

```
## view
view(UI)を作成していく。
``ConsumerWidget``はbuildメソッドに第２パラメータ(ref)があること以外はStatelessWidgetと同じ。
```dart:first_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_datetime_picker_plus/flutter_datetime_picker_plus.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mvvm_test/view/second_page.dart';
import 'package:mvvm_test/view_model/age_notifier.dart';

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final ageNotifier = ref.watch(ageProvider.notifier);
    final ageState = ref.watch(ageProvider);
    return Scaffold(
      appBar: AppBar(
        title: const Text('FirstPage'),
        centerTitle: true,
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: Center(
        child: Column(
          children: [
            Text("${ageState.year}年${ageState.month}月${ageState.day}日"),
            ElevatedButton(
              child: const Text('生年月日を入力する'),
              onPressed: (){
                // 生年月日を選択することができるドラムロールを表示する
                DatePicker.showDatePicker(context,
                  showTitleActions: true,
                  currentTime: DateTime.now(),
                  locale: LocaleType.jp,
                  minTime: DateTime(1900, 1, 1),
                  maxTime: DateTime(2023, 7, 18),
                  onChanged: (date) {
                    // modelを更新する(age以外)
                    ageNotifier.changeBirthday(date.year, date.month, date.day);
                    // 更新したmodelを使って年齢を算出する(modelのageのみ更新する)
                    ageNotifier.culculateAge();
                  },
                );
              }, 
            ),
            ElevatedButton(
              child: const Text('次のページへ'),
              onPressed: (){
                Navigator.of(context).push(
                  MaterialPageRoute(
                    builder: (context) => const SecondPage(),
                  ),
                );
              },
            ),
          ],
        ),
      ),
    );
  }
}

```
```dart:second_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mvvm_test/view_model/age_notifier.dart';

class SecondPage extends ConsumerWidget {
  const SecondPage({super.key});
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final ageState = ref.watch(ageProvider);
    return Scaffold(
      appBar: AppBar(
        title: const Text('SecondPage'),
        centerTitle: true,
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: Center(
        child: Column(
          children: [
            Text("誕生日は${ageState.year}年${ageState.month}月${ageState.day}日です"),
            Text('${ageState.age}歳です')
          ],
        ),
      ),
    );
  }
}
```
以上で完成。
２画面にした理由はどこからでもプロバイダーにアクセスできるという使用感を確めたかったからだったが、コードが簡易すぎてそりゃそうって感じになってしまった。
あと、modelやviewmodelの命名だが、Pageという文字を入れてページごとに作っているものが多く見受けられた。今回のように複数の画面で利用するということもあると思うが、これで合ってたのか違和感を拭えない。