FlutterでProviderパッケージ利用して、ログイン画面を作成する

Flutterでログイン画面を作成したので、メモしておきます。
状態管理は、[公式ドキュメント](https://flutter.dev/docs/development/data-and-backend/state-mgmt/simple)で利用しているproviderパッケージを利用します。

## 作成したもの

* ログイン正常の動作

![login_ok.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73176/a877d718-631a-d593-c884-cea9827bc999.gif)

* ログインエラーの動作

![login_error.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/73176/1a2d203a-e797-cde2-b42f-bc644969dbfd.gif)

## レポジトリ

https://github.com/Thirosue/flutter_sample/tree/main/login_form_sample/flutter_app

## ディレクトリ構成

```bash
/path/to/directory/lib
|--main.dart
|--repository
|  |--auth_repository.dart
|--ui
|  |--index_page.dart
|  |--login_model.dart
|  |--login_page.dart
```

## パッケージ追加

pubspec.yamlに以下パッケージを追加し、パッケージをインポート（`dart pub get`）します。

```./pubspec.yaml
dependencies:
  flutter:
    sdk: flutter

  provider: ^5.0.0 # <--- 追加
  font_awesome_flutter: ^9.0.0-nullsafety # <--- パスワードマスク用のアイコン利用のため
```

## 実装

* 認証レポジトリ追加

実際は、認証API（Firebase Authenticationなど）などをコールしますが、サンプル実装のため、必ず成功（true）を返します。

```dart:lib/repository/auth_repository.dart
class AuthRepository {
  Future<bool> auth() {
    return Future.value(true);
  }
}
```

* 状態クラス追加

ChangeNotifierを継承して、状態クラスを作成します。
id, password, showPassword（=パスワード表示・非表示）などのフィールドと、認証や、パスワード表示切り替えなどの処理を持ちます。

```dart:lib/ui/login_model.dart
class LoginModel extends ChangeNotifier {
  final AuthRepository repository;
  String id = '';
  String password = '';
  String message = '';
  bool showPassword = false; //パスワードを平文で表示する

  LoginModel(this.repository);

  void setMessage(String value) { //エラーメッセージ設定
    message = value;
    notifyListeners();
  }

  void togglePasswordVisible() { //パスワード表示切り替え
    showPassword = !showPassword;
    notifyListeners();
  }

  String? emptyValidator(String? value) { //必須入力チェック
    if (value == null || value.isEmpty) {
      return '入力してください';
    }
    return null;
  }

  Future<bool> auth() async {
    print("id: $id, password: $password");
    var results = await repository.auth();
    return results;
  }
}
```

* 画面(Widget)実装

ChangeNotifierProviderを利用して、状態クラスを子供のWidgetで利用できるようにします。

```dart:lib/ui/login_page.dart
class LoginPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) => LoginModel(
        AuthRepository(),
      ),
      child: LoginApp(),
    );
  }
}
```

状態クラスは、以下メソッドを通じて利用します。
* `context.read`（変更監視なし）
* `context.watch`（変更監視あり）

```dart:lib/ui/login_page.dart
class LoginApp extends StatelessWidget {
  final _formKey = GlobalKey<FormState>(); // 入力フォーム

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: Container(
          padding: const EdgeInsets.all(24),
          child: Form(
            key: _formKey,
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                TextFormField(
                  decoration: const InputDecoration(
                    labelText: 'UserId',
                    hintText: 'ユーザIDを入力してください',
                  ),
                  validator: context.read<LoginModel>().emptyValidator, // 入力チェック
                  onSaved: (value) => context.read<LoginModel>().id = value!, // save() 時に同期
                ),
                TextFormField(
                  obscureText: !context.watch<LoginModel>().showPassword, // パスワード表示状態を監視(watch)
                  decoration: InputDecoration(
                    labelText: 'Password',
                    hintText: 'パスワードを入力してください',
                    suffixIcon: IconButton(
                      icon: Icon(context.watch<LoginModel>().showPassword // パスワード表示状態を監視(watch)
                          ? FontAwesomeIcons.solidEye
                          : FontAwesomeIcons.solidEyeSlash),
                      onPressed: () =>
                          context.read<LoginModel>().togglePasswordVisible(), // パスワード表示・非表示をトグルする
                    ),
                  ),
                  validator: context.read<LoginModel>().emptyValidator, // 入力チェック
                  onSaved: (value) =>
                      context.read<LoginModel>().password = value!,
                ),
                Container( // エラー文言表示エリア
                  margin: EdgeInsets.fromLTRB(0, 16, 0, 8),
                  child: Text(
                    context.watch<LoginModel>().message,
                    style: TextStyle(
                      fontSize: 16,
                      color: Colors.red,
                    ),
                  ),
                ),
                Container(
                  width: double.infinity,
                  child: Padding(
                    padding: const EdgeInsets.symmetric(vertical: 16),
                    child: ElevatedButton(
                      onPressed: () async { // ログインボタンアクション
                        context.read<LoginModel>().setMessage(''); // エラーメッセージを空に

                        if (_formKey.currentState!.validate()) { // 入力チェック
                          _formKey.currentState!.save(); // 入力チェックOK -> フォームの値を同期する

                          var response =
                              await context.read<LoginModel>().auth();
                          print('auth response = $response');

                          if (response) {
                            Navigator.push( // 画面遷移
                              context,
                              MaterialPageRoute(
                                builder: (context) => IndexPage(),
                              ),
                            );

                            ScaffoldMessenger.of(context).showSnackBar( // SnackBar表示
                              SnackBar(
                                content: Text('ログインしました'),
                              ),
                            );
                          } else {
                            context
                                .read<LoginModel>()
                                .setMessage('パスワードが誤っています'); // エラーメッセージセット
                          }
                        }
                      },
                      child: const Text('ログイン'),
                    ),
                  ),
                )
              ],
            ),
          ),
        ),
      ),
    );
  }
}
```

# 終わりに

状態管理を切り出すことで、Widgetのコードが見通しがよくなりました。
[riverpod](https://pub.dev/packages/riverpod)や[flutter_hooks](https://pub.dev/packages/flutter_hooks)あたりも今後試していきたいと思います。

次回は、今回作成した画面を元に、Widgetのテストを記載したいと思います。

# 参考

- [Simple app state management - 公式](https://flutter.dev/docs/development/data-and-backend/state-mgmt/simple)
- [FlutterのProviderパッケージを使いこなす](https://itome.team/blog/2019/12/flutter-advent-calendar-day7/)