flutter_riverpodでアプリをMVVMにする

# はじめに
前回のログインアプリをflutter_riverpodを使用して、MVVM風にリファクタしてみました。
前回作成したアプリ(https://qiita.com/yufuku/items/24dac97e6052b2571386)


今回のゴールはview(ui)側で実装していた処理をviewModelに移行して、view側ではstateを持たないようにすることです。

最初のフォルダ構成とコードは下記になります。

```shell
└──lib
   ├── main.dart
   ├── requester
   │   └── requester.dart
   └── ui
       ├── hello.dart
       └── login.dart
```

```dart:lib/main.dart
import 'package:flutter/material.dart';
import 'package:authentication_frontend/ui/login.dart';

void main() {
  runApp(const LoginApp());
}
```

```dart:lib/ui/login.dart
import 'package:authentication_frontend/ui/hello.dart';
import 'package:authentication_frontend/requester/requester.dart';
import 'package:flutter/material.dart';

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

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'ログイン画面',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: const LoginPage(title: 'Login Page'),
    );
  }
}

class LoginPage extends StatefulWidget {
  const LoginPage({Key? key, required this.title}) : super(key: key);

  final String title;

  @override
  State<LoginPage> createState() => _LoginPageState();
}

class _LoginPageState extends State<LoginPage> {
  final _userNameTextController = TextEditingController();
  final _passwordNameTextController = TextEditingController();
  final FocusNode _userNamefocusNode = FocusNode();
  final FocusNode _passwordNameFocusNode = FocusNode();
  var _errorMessage = "";

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(_errorMessage, style: const TextStyle(color: Colors.red)),
            TextField(
              controller: _userNameTextController,
              decoration: const InputDecoration(
                filled: true,
                labelText: 'Username',
              ),
              focusNode: _userNamefocusNode,
            ),
            const SizedBox(height: 12.0),
            TextField(
              controller: _passwordNameTextController,
              decoration: const InputDecoration(
                filled: true,
                labelText: 'Password',
              ),
              focusNode: _passwordNameFocusNode,
              obscureText: true,
            ),
            ButtonBar(
              children: <Widget>[
                TextButton(
                  child: const Text('CANCEL'),
                  onPressed: () {
                    _userNameTextController.clear();
                    _passwordNameTextController.clear();
                  },
                ),
                ElevatedButton(
                    child: const Text('LOGIN'),
                    onPressed: () {
                      Requester()
                          .loginRequester(_userNameTextController.text,
                              _passwordNameTextController.text)
                          .then((_) {
                        setState(() {
                          _errorMessage = "";
                        });
                        Navigator.push(context,
                            MaterialPageRoute(builder: (context) => Hello()));
                      }).onError((error, stackTrace) {
                        debugPrint(error.toString());
                        _userNameTextController.clear();
                        _passwordNameTextController.clear();
                        setState(() {
                          _errorMessage = "ログインに失敗しました。ユーザー名かパスワードが間違っています。";
                        });
                      });
                    }),
                ElevatedButton(
                    child: const Text('SIGNUP'),
                    onPressed: () {
                      Requester()
                          .signUpRequester(_userNameTextController.text,
                              _passwordNameTextController.text)
                          .then((_) {
                        setState(() {
                          _errorMessage = "";
                        });
                        Navigator.push(context,
                            MaterialPageRoute(builder: (context) => Hello()));
                      }).onError((error, stackTrace) {
                        debugPrint(error.toString());
                        _userNameTextController.clear();
                        _passwordNameTextController.clear();
                        setState(() {
                          _errorMessage = "ユーザーの作成に失敗しました。既に登録済みのユーザーです。";
                        });
                      });
                    }),
              ],
            ),
          ],
        ),
      ),
    );
  }
}

```

```dart:lib/ui/hello.dart
import 'package:authentication_frontend/requester/requester.dart';
import 'package:flutter/material.dart';

class Hello extends StatefulWidget {
  const Hello({Key? key}) : super(key: key);

  @override
  _HelloState createState() => _HelloState();
}

class _HelloState extends State<Hello> {
  var _message = "";

  @override
  void initState() {
    super.initState();
    Requester().helloRequester().then((value) {
      setState(() {
        _message = value;
      });
    }).onError((error, stackTrace) {
      showDialog(
          context: context,
          builder: (_) {
            return AlertDialog(
              title: Text("認証に失敗しました。再ログインをお願いします。"),
              actions: [
                TextButton(
                    onPressed: () => Navigator.pop(context), child: Text("OK")),
              ],
            );
          }).then((_) {
        Navigator.pop(context);
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Hello"),
        centerTitle: true,
        automaticallyImplyLeading: false,
        actions: [
          TextButton(
            onPressed: () {
              Requester().logoutRequester().then((_) {
                Navigator.pop(context);
              });
            },
            child: const Text('LOGOUT',
                style: TextStyle(
                    color: Colors.white,
                    fontWeight: FontWeight.bold,
                    fontSize: 16.0)),
          ),
        ],
      ),
      body: Padding(
        padding: EdgeInsets.only(top: 100),
        child: Center(
          child: Column(
            children: [Text(_message, style: TextStyle(fontSize: 50.0))],
          ),
        ),
      ),
    );
  }
}
```
今回は上記のファイルを変更していきます。
また、今回は変更の対象ではないですが、apiのリクエスト部分の処理はrequester.dartにまとめています。

```dart:lib/requester/requester.dart
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';

class TokenExipireException implements Exception {
  final String message;
  const TokenExipireException(this.message);

  @override
  String toString() => message;
}

class Requester {
  String uri = defaultTargetPlatform == TargetPlatform.android
      ? 'http://10.0.2.2:8080/v1/'
      : 'http://127.0.0.1:8080/v1/';

  Map<String, String> headers = {
    "Content-Type": "application/json",
  };
  final storage = new FlutterSecureStorage();

  Requester() {}

  Future<void> loginRequester(String name, String password) async {
    var loginUri = uri + "auth/login";

    var request = AuthRequest(name: name, password: password);

    final response = await http.post(Uri.parse(loginUri),
        body: json.encode(request.toJson()), headers: headers);

    if (response.statusCode == 200) {
      Map<String, dynamic> decoded = json.decode(response.body);
      var loginResponse = AuthResponse.fromJson(decoded);
      debugPrint(loginResponse.accessToken);
      await storage.write(key: "accessToken", value: loginResponse.accessToken);
      await storage.write(key: "refreshToken", value: loginResponse.refreshToken);
    } else {
      throw Exception("Login Error");
    }
  }

  Future<void> signUpRequester(String name, String password) async {
    var signUpUri = uri + "auth/signup";

    var request = AuthRequest(name: name, password: password);

    final response = await http.post(Uri.parse(signUpUri),
        body: json.encode(request.toJson()), headers: headers);

    if (response.statusCode == 200) {
      Map<String, dynamic> decoded = json.decode(response.body);
      var signUpResponse = AuthResponse.fromJson(decoded);
      await storage.write(
          key: "accessToken", value: signUpResponse.accessToken);
      await storage.write(
          key: "refreshToken", value: signUpResponse.refreshToken);
      debugPrint(signUpResponse.accessToken);
    } else {
      throw Exception("Sign UP Error");
    }
  }

  Future<String> helloRequester() async {
    var helloUri = uri + "hello";
    var accessToken = await storage.read(key: "accessToken");
    headers["Authorization"] = accessToken ?? "";

    debugPrint("send helloRequester");
    final response = await http.get(Uri.parse(helloUri), headers: headers);

    if (response.statusCode == 200) {
      Map<String, dynamic> decoded = json.decode(response.body);
      var helloResponse = HelloResponse.fromJson(decoded);
      return helloResponse.message;
    } else if (response.statusCode == 401 || response.statusCode == 404) {
      debugPrint("send refreshTokenRequester");
      await refreshTokenRequester();
      var value = await helloRequester();
      return value;
    } else {
      throw Exception("Hello Error");
    }
  }

  Future<void> refreshTokenRequester() async {
    var refreshTokenUri = uri + "auth/refresh_token";
    var refreshToken = await storage.read(key: "refreshToken");

    var request = RefreshTokenRequest(refreshToken: refreshToken ?? "");

    final response = await http.post(Uri.parse(refreshTokenUri),
        body: json.encode(request.toJson()), headers: headers);

    if (response.statusCode == 200) {
      Map<String, dynamic> decoded = json.decode(response.body);
      var refreshTokenResponse = AuthResponse.fromJson(decoded);
      await storage.write(
          key: "accessToken", value: refreshTokenResponse.accessToken);
      await storage.write(
          key: "refreshToken", value: refreshTokenResponse.refreshToken);
      debugPrint(refreshTokenResponse.accessToken);
    } else {
      throw Exception("Token Refresh Error");
    }
  }

  Future<void> logoutRequester() async {
    var logoutUri = uri + "auth/logout";
    var accessToken = await storage.read(key: "accessToken");
    headers["Authorization"] = accessToken ?? "";

    final response = await http.post(Uri.parse(logoutUri), headers: headers);

    if (response.statusCode == 201) {
      await storage.delete(key: "accessToken");
    }
  }


}

class AuthResponse {
  final String accessToken;
  final String refreshToken;

  AuthResponse.fromJson(Map<String, dynamic> json)
      : accessToken = json['access_token'],
        refreshToken = json['refresh_token'];
}

class AuthRequest {
  final String name;
  final String password;

  AuthRequest({
    this.name = "",
    this.password = "",
  });

  Map<String, dynamic> toJson() => {
        'password': password,
        'user_name': name,
      };
}

class HelloResponse {
  final message;

  HelloResponse.fromJson(Map<String, dynamic> json) : message = json['message'];
}

class RefreshTokenRequest {
  final String refreshToken;

  RefreshTokenRequest({
    this.refreshToken = "",
  });

  Map<String, dynamic> toJson() => {
        'refresh_token': refreshToken,
      };
}
```
# 環境

```shell
$ flutter doctor
Doctor summary (to see all details, run flutter doctor -v):
[✓] Flutter (Channel stable, 2.5.3, on macOS 11.2.3 20D91 darwin-x64, locale ja-JP)
[✓] Android toolchain - develop for Android devices (Android SDK version 31.0.0)
[✓] Xcode - develop for iOS and macOS
[✓] Chrome - develop for the web
[✓] Android Studio (version 2020.3)
[✓] VS Code (version 1.62.3)
```
```shell
dependencies:
  flutter:
    sdk: flutter
  http: ^0.13.4
  flutter_secure_storage: ^5.0.2
  flutter_riverpod: ^1.0.2
```
# 実装
下記の手順で、uiからstateと処理のロジックを分離していきます。
・flutter_riverpodの導入
・ProvierScopeとConsumerWighetの適用
・login.dartの変更
・hello.dartの変更

最終的なフォルダ構成は下記になります。

```shell
└─ lib
   ├── main.dart
   ├── requester
   │   └── requester.dart
   └── ui
       ├── hello
       │   ├── hello.dart
       │   └── hello_view_model.dart
       └── login
           ├── login.dart
           └── login_view_model.dart
```

## flutter_riverpodの導入
まずは、flutter_riverpodをインストールします。
https://pub.dev/packages/flutter_riverpod/install
 
```shell
flutter pub add flutter_riverpod
```
## provierstateの導入とConsumerWighetの適用
flutter_riverpodを使用するためにrunApp時にProviderScopeを適用します。

```dart:lib/main.dart
void main() {
  runApp(ProviderScope(child: const LoginApp()));
}
```

次に、それぞれのuiファイルのStatefulWidgetをConsumerWidgetに変更し、Stateをextendsしていたクラスでの処理を全てConsumerWidgetに移します。
Stateを管理するメソッドは(setStateやinitStateなど)使用できないので、削除します。
最後に、buildの引数にWighetRefを追加します。
uiに対して、これらの変更を加えたコードが下記になります。

```dart:lib/ui/login/login.dart
class LoginApp extends StatelessWidget {
  const LoginApp({Key? key}) : super(key: key);

  // This widget is the root of your application.
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'ログイン画面',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: LoginPage(title: 'Login Page'),
    );
  }
}

class LoginPage extends ConsumerWidget {
  LoginPage({Key? key, required this.title}) : super(key: key);

  final String title;

  final _userNameTextController = TextEditingController();
  final _passwordNameTextController = TextEditingController();
  final FocusNode _userNamefocusNode = FocusNode();
  final FocusNode _passwordNameFocusNode = FocusNode();
  var _errorMessage = "";

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(
        title: Text(title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(_errorMessage, style: const TextStyle(color: Colors.red)),
            TextField(
              controller: _userNameTextController,
              decoration: const InputDecoration(
                filled: true,
                labelText: 'Username',
              ),
              focusNode: _userNamefocusNode,
            ),
            const SizedBox(height: 12.0),
            TextField(
              controller: _passwordNameTextController,
              decoration: const InputDecoration(
                filled: true,
                labelText: 'Password',
              ),
              focusNode: _passwordNameFocusNode,
              obscureText: true,
            ),
            ButtonBar(
              children: <Widget>[
                TextButton(
                  child: const Text('CANCEL'),
                  onPressed: () {
                    _userNameTextController.clear();
                    _passwordNameTextController.clear();
                  },
                ),
                ElevatedButton(
                    child: const Text('LOGIN'),
                    onPressed: () {
                      Requester()
                          .loginRequester(_userNameTextController.text,
                              _passwordNameTextController.text)
                          .then((_) {
                        Navigator.push(context,
                            MaterialPageRoute(builder: (context) => Hello()));
                      }).onError((error, stackTrace) {
                        debugPrint(error.toString());
                        _userNameTextController.clear();
                        _passwordNameTextController.clear();
                      });
                    }),
                ElevatedButton(
                    child: const Text('SIGNUP'),
                    onPressed: () {
                      Requester()
                          .signUpRequester(_userNameTextController.text,
                              _passwordNameTextController.text)
                          .then((_) {
                        Navigator.push(context,
                            MaterialPageRoute(builder: (context) => Hello()));
                      }).onError((error, stackTrace) {
                        debugPrint(error.toString());
                        _userNameTextController.clear();
                        _passwordNameTextController.clear();
                      });
                    }),
              ],
            ),
          ],
        ),
      ),
    );
  }
}
```

```dart:lib/ui/hello/hello.dart
class Hello extends ConsumerWidget {
  Hello({Key? key}) : super(key: key);

  var _message = "";

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
      appBar: AppBar(
        title: Text("Hello"),
        centerTitle: true,
        automaticallyImplyLeading: false,
        actions: [
          TextButton(
            onPressed: () {
              Requester().logoutRequester().then((_) {
                Navigator.pop(context);
              });
            },
            child: const Text('LOGOUT',
                style: TextStyle(
                    color: Colors.white,
                    fontWeight: FontWeight.bold,
                    fontSize: 16.0)),
          ),
        ],
      ),
      body: Padding(
        padding: EdgeInsets.only(top: 100),
        child: Center(
          child: Column(
            children: [Text(_message, style: TextStyle(fontSize: 50.0))],
          ),
        ),
      ),
    );
  }
}
```
## login.dartの変更
ここから、各uiのState管理やロジック部分をviewModelに移します。
まずは、login.dartをlogin_view_model.dartに切り出していきます。
login画面ではログイン処理に失敗した際にエラーメッセージ(errorMessage)を表示しています。
まず、これをlogin_view_model.dartに移します。

```dart:/lib/ui/login/login_view_model.dart
StateProvider<String> errorMessageProvider = StateProvider((ref) => '');
```

次に、errorMessageProviderの変更をui側で検知できるように、ConsumerWighet(LoginPage)のbuild内でwatchしておく。

```dart:lib/ui/login/login.dart
class LoginPage extends ConsumerWidget {
・・・
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final errorMessage = ref.watch(errorMessageProvider);
・・・
```

`ref.watch(errorMessageProvider)`を追加しておくことで、errorMessageProviderが変更すると、ui側に検知されるようになります。

次に、ログインとサインアップのapiリクエストもviewModelを経由するように変更します。

```dart:/lib/ui/login/login_view_model.dart
StateProvider<String> errorMessageProvider = StateProvider((ref) => '');

Future<void> loginRequest(WidgetRef ref, String name, String password) async {
  return Requester()
      .loginRequester(name, password)
      .onError((error, stackTrace) {
    ref.watch(errorMessageProvider.notifier).state = "ログインに失敗しました";
    throw Exception(error.toString());
  });
}

Future<void> signUpRequest(WidgetRef ref, String name, String password) async {
  return Requester()
      .signUpRequester(name, password)
      .onError((error, stackTrace) {
    ref.watch(errorMessageProvider.notifier).state = "サインアップに失敗しました";
    throw Exception(error.toString());
  });
}
```

それぞれの、request処理はui側から、ユーザー名とパスワードをもらってRequesterの処理を呼び出すだけです。
エラーになった際には、errorMessageProviderの値を`ref.watch(errorMessageProvider.notifier).state`で値を書き換えます。
こうすることで、ui側で変更が検知されてuiの更新が行われます。
最後に、このviewModelのrequest処理をui側で呼び出します。

ui部分の最終的なコードは下記になります。

```dart:lib/ui/login/login.dart
class LoginPage extends ConsumerWidget {
  LoginPage({Key? key, required this.title}) : super(key: key);

  final String title;

  final _userNameTextController = TextEditingController();
  final _passwordNameTextController = TextEditingController();
  final FocusNode _userNamefocusNode = FocusNode();
  final FocusNode _passwordNameFocusNode = FocusNode();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final errorMessage = ref.watch(errorMessageProvider);
    return Scaffold(
      appBar: AppBar(
        title: Text(title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Text(errorMessage, style: const TextStyle(color: Colors.red)),
            TextField(
              controller: _userNameTextController,
              decoration: const InputDecoration(
                filled: true,
                labelText: 'Username',
              ),
              focusNode: _userNamefocusNode,
            ),
            const SizedBox(height: 12.0),
            TextField(
              controller: _passwordNameTextController,
              decoration: const InputDecoration(
                filled: true,
                labelText: 'Password',
              ),
              focusNode: _passwordNameFocusNode,
              obscureText: true,
            ),
            ButtonBar(
              children: <Widget>[
                TextButton(
                  child: const Text('CANCEL'),
                  onPressed: () {
                    _userNameTextController.clear();
                    _passwordNameTextController.clear();
                  },
                ),
                ElevatedButton(
                    child: const Text('LOGIN'),
                    onPressed: () {
                      loginRequest(ref, _userNameTextController.text,
                              _passwordNameTextController.text)
                          .then((_) {
                        Navigator.push(context,
                            MaterialPageRoute(builder: (context) => Hello()));
                      }).onError((error, stackTrace) {
                        _userNameTextController.clear();
                        _passwordNameTextController.clear();
                      });
                    }),
                ElevatedButton(
                    child: const Text('SIGNUP'),
                    onPressed: () {
                      signUpRequest(ref, _userNameTextController.text,
                              _passwordNameTextController.text)
                          .then((_) {
                        Navigator.push(context,
                            MaterialPageRoute(builder: (context) => Hello()));
                      }).onError((error, stackTrace) {
                        _userNameTextController.clear();
                        _passwordNameTextController.clear();
                      });
                    }),
              ],
            ),
          ],
        ),
      ),
    );
  }
}
```
request内でerrorMessageProviderの更新をui側に検知するために、requestを呼び出す際に、ui側のWidgetRefを渡しています。

ログインページのエラー時の挙動はこのようになります。
![ezgif.com-gif-maker (3).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680074/6a976e05-6b4b-d4c6-b08d-de4d62282d09.gif)

## hello.dartの変更
次に、hello.dartのロジックをviewModelを移行させます。
まず、この画面では画面が描画せれたタイミングでapiにリクエストし、返ってきた値を表示していました。
なので、viewModelでapiリクエストを実行しレスポンスが返ってきたタイミングでuiに通知してあげます。
それを実現するために、FutureProviderを使用します。

```dart:lib/ui/hello/hello_view_model.dart
FutureProvider<String> helloRequestProvider =
    FutureProvider((ref) async {
  var message = await Requester().helloRequester();

  return message;
});
```
この、`helloRequestProvider`をCosumerWighetのbuildでwatchすることで画面が描画されるタイミングでhelloRequestProvider内の処理が呼び出されます。
そして、helloRequestProviderの処理が完了した時のui側の処理をwhenで書きます。

```dart:lib/ui/hello/hello.dart
class Hello extends ConsumerWidget {
  Hello({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final messageProvider = ref.watch(helloRequestProvider);
・・・
    body: Padding(
        padding: EdgeInsets.only(top: 100),
        // messageProviderでのapiリクエスト完了後の処理
        child: messageProvider.when(
          data: (message) => Center(
            child: Column(
              children: [Text(message, style: TextStyle(fontSize: 50.0))],
            ),
          ),
          error: (error, stackTrace) {
              showDialog(
                  context: context,
                  builder: (_) {
                    return AlertDialog(
                      title: Text("認証に失敗しました。再ログインをお願いします。"),
                      actions: [
                        TextButton(
                            onPressed: () => Navigator.pop(context),
                            child: Text("OK")),
                      ],
                    );
                  }).then((_) {
                Navigator.pop(context);
              });
          },
          loading: () => AspectRatio(
            aspectRatio: 0.01,
            child: const CircularProgressIndicator(),
          ),
```
whenの処理では、リクエスト成功時はレスポンスのメッセージを画面に描画しています。
エラー時にはダイアログを出して、ログイン画面に戻しています`Navigator.pop(context)`。
しかし、これだとshowDialogでエラーになってしまいます。

```shell:エラーメッセージ
This Overlay widget cannot be marked as needing to build because the framework is already in the process of building widgets.
```
これは、Wighetのbuild時に状態変更をできないという内容です。
これを回避するために、showDialogをFutureで括ります。

```dart:lib/ui/hello/hello.dart
Future(() {
  showDialog(
      context: context,
      builder: (_) {
        return AlertDialog(
          title: Text("認証に失敗しました。再ログインをお願いします。"),
          actions: [
            TextButton(
                onPressed: () => Navigator.pop(context),
                child: Text("OK")),
          ],
        );
      }).then((_) {
    Navigator.pop(context);
  });
});
```

これで、画面の描画時にリクエストを送りレスポンスの内容によって、uiを変更することができました。

しかし、このままだと初回にhello画面に遷移した際はリクエストが送られますが、2回目以降はリクエストされません(ログアウト→ログインした際など)。
画面が呼び出されるたびに毎回リクエストをして欲しいので、hello画面でNavigator.pop(context)が呼ばれて画面を閉じる際に`messageProvider`を解放するようにします。
それを実現するために、`FutureProvider`の代わりに`AutoDisposeFutureProvider`を使用します。
こうすることで、画面が閉じるタイミングでui側のbuild時にhelloRequestProviderをwatchしていたmessageProviderが解放されます。
なので、画面再描画時もmessageProviderが再作成され、毎回処理が走るようになります。

これを踏まえた最終的なコードは下記になります。

```dart:lib/ui/hello/hello_view_model.dart
AutoDisposeFutureProvider<String> helloRequestProvider =
    FutureProvider.autoDispose((ref) async {
  var message = await Requester().helloRequester();

  return message;
});

Future<void> logoutRequest() async {
  return Requester().logoutRequester();
}
```

```dart:lib/ui/hello/hello.dart

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    debugPrint("build");
    final messageProvider = ref.watch(helloRequestProvider);
    return Scaffold(
      appBar: AppBar(
        title: Text("Hello"),
        centerTitle: true,
        automaticallyImplyLeading: false,
        actions: [
          TextButton(
            onPressed: () {
              logoutRequest().then((_) {
                Navigator.pop(context);
              });
            },
            child: const Text('LOGOUT',
                style: TextStyle(
                    color: Colors.white,
                    fontWeight: FontWeight.bold,
                    fontSize: 16.0)),
          ),
        ],
      ),
      body: Padding(
        padding: EdgeInsets.only(top: 100),
        child: messageProvider.when(
          data: (message) => Center(
            child: Column(
              children: [Text(message, style: TextStyle(fontSize: 50.0))],
            ),
          ),
          error: (error, stackTrace) {
            Future(() {
              showDialog(
                  context: context,
                  builder: (_) {
                    return AlertDialog(
                      title: Text("認証に失敗しました。再ログインをお願いします。"),
                      actions: [
                        TextButton(
                            onPressed: () => Navigator.pop(context),
                            child: Text("OK")),
                      ],
                    );
                  }).then((_) {
                Navigator.pop(context);
              });
            });
          },
          loading: () => AspectRatio(
            aspectRatio: 0.01,
            child: const CircularProgressIndicator(),
          ),
        ),
      ),
    );
  }
}
```

ログアウト処理についてもリクエストをviewModel側に移しましたが大した変更ではないので、説明は省略します。

最終的な画面が下記になります。
![ezgif.com-gif-maker (5).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/680074/f038dc62-1d4a-5a30-9de4-fd753295f5d0.gif)


# おわりに
今回は、flutter_riverpodで画面側の状態管理をviewからviewModelに移すことで、MVVMな設計でログインアプリを書き換えてみました。
今回のコードは下記になります。
https://github.com/fu-yuta/authentication_frontend/tree/mvvm

リファクタしただけだと、あまり利点が感じられなかったので、次はテストとか作りたいです。