FlutterアプリをCleanArchitecture + TDDで書く【最終回】(UI実装)

7 min read読了の目安(約6500字

今回で最終回です。ここまでお読みいただいた方は本当にありがとうございます。

タイトルに"TDD"とついていますが、本記事のUI実装では一切テストは出てこないです。。いままでTDDで作ってきたユースケース・リポジトリ(データアクセス)・プレゼンテーションを結合し、実際に動くデモをつくりたいと思います。

デモ

とてもシンプル(?)な画面をつくります。

ディレクトリ構成

早速UIを実装していきたいところですが、いままでクラスはすべてtestクラスの中に定義しちゃってましたので、あらためて配置していきたいと思います。以下の構成にし、クラスを移動しました。

lib
├── domain
│   └── user
│       ├── user.dart
│       └── user_repository.dart
├── features
│   └── login
│       ├── models
│       │   ├── user_login_model.dart
│       │   └── user_login_state.dart
│       └── pages
│           └── login_page.dart
├── infrastructure
│   ├── repositories
│   │   └── user_remote_data_source.dart
│   ├── requests
│   │   └── login_request.dart
│   └── responses
│       ├── user_response.dart
│       └── user_response.g.dart
|── usecases
│   └── login.dart
├── injection_container.dart
└── main.dart

基本的には以下サイトを参考にしていますが...

(本シリーズで何度も登場してますが、わかりやすく丁寧な解説サイトなのでぜひご覧ください。。)
features配下に、domain/data/presentationというパッケージを切っているようでした。これだと機能を追加する毎に切る必要があるかなと思い大変そうなので、以下のQiita(Laravelですが...)を参考にinfrastructure(参考サイトのdataに該当)/usecases/domainlib直下に移動してみました。また、features配下はプレゼンテーションに関するクラスしか置いてないので、presentationディレクトリもなくしちゃいました。

またテストも上記のディレクトリ構成に合わせて移動します。
(移動したあと flutter pub run build_runner build を実行してます)

test
├── features
│   └── login
│       └── models
│           ├── user_login_model_test.dart
│           └── user_login_model_test.mocks.dart
├── infrastructure
│   └── repositories
│       ├── user_remote_data_source_test.dart
│       └── user_remote_data_source_test.mocks.dart
└── usecases
    ├── login_test.dart
    └── login_test.mocks.dart

依存性注入

get_itパッケージをつかいます。
また、WidgetへのプレゼンテーションモデルクラスChangeNotifierを渡すためにproviderパッケージもつかいます。

dependencies:
  # ...
  get_it: ^6.0.0
  provider: ^5.0.0

lib直下にinjection_container.dartを追加し、以下のように書きました。

import 'package:get_it/get_it.dart';
import 'package:http/http.dart' as http;
import 'package:tdd_login_demo/usecases/login.dart';

import 'domain/user/user_repository.dart';
import 'features/login/models/user_login_model.dart';
import 'infrastructure/repositories/user_remote_data_source.dart';

final sl = GetIt.instance;

void init() {
  // models
  sl.registerFactory<UserLoginModel>(() => UserLoginModel(useCase: sl()));
  // useCases
  sl.registerLazySingleton<Login>(() => Login(repository: sl()));
  // repositories
  sl.registerLazySingleton<UserRepository>(() => UserRemoteDataSource(client: sl()));
  // others
  sl.registerLazySingleton(() => http.Client());
}

UserLoginModelproviderパッケージにより生成するタイミングにあわせて生成するのでregisterFactory、それ以外のクラスは特に状態管理がないのでregisterLazySingletonでシングルトンです。
そしてアプリのエントリーポイントであるmainは以下のようになります。

import 'package:provider/provider.dart';
import 'package:tdd_login_demo/injection_container.dart' as di;
import 'package:flutter/material.dart';

import 'features/login/models/user_login_model.dart';
import 'features/login/pages/login_page.dart';

void main() async {
  di.init();
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'TDD Login Demo',
      home: ChangeNotifierProvider(
          create: (context) => di.sl<UserLoginModel>(),
          child: LoginPage()
      ),
    );
  }
}

LoginPage内でproviderを使ってもいいですが、この画面しかないので適当にいれちゃいました。(できるだけサービスロケータへの参照が散らばらない方がいいと思いますし・・・)
最後にLoginPageの実装です!長いのでトグルにしました...

LoginPage
class LoginPage extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return Scaffold(
      body: Consumer<UserLoginModel>(
          builder: (context, model, child) {
            return Container(
              padding: const EdgeInsets.all(32),
              child: Column(
                mainAxisAlignment: MainAxisAlignment.center,
                crossAxisAlignment: CrossAxisAlignment.center,
                children: [
                  LoginStateWidget(
                      canLogin: model.canLogin,
                      state: model.state
                  ),
                  Container(
                    height: 44,
                    child: TextField(
                      onChanged: (newValue) {
                        model.inputEmail(newValue);
                      },
                    ),
                  ),
                  SizedBox(height: 8),
                  Container(
                    height: 44,
                    child: TextField(
                      onChanged: (newValue) {
                        model.inputPassword(newValue);
                      },
                    ),
                  ),
                  SizedBox(height: 32),
                  ElevatedButton(
                    child: Text("ログイン"),
                    onPressed: () {
                      if (model.canLogin) {
                        model.login();
                      }
                    },
                  )
                ],
              ),
            );
          }
      ),
    );
  }
}

class LoginStateWidget extends StatelessWidget {
  final UserLoginState state;
  final bool canLogin;

  const LoginStateWidget({
    Key? key,
    required this.canLogin,
    required this.state
  }) : super(key: key);

  
  Widget build(BuildContext context) {
    if (!canLogin) {
      return Text("😐", style: TextStyle(fontSize: 24),);
    }

    final _state = state;
    if (_state is Loaded) {
      return Text("😆(user id: ${_state.result.userId})",
        style: TextStyle(fontSize: 24),);
    } else if (_state is Loading) {
      return CircularProgressIndicator();
    } else {
      return Text("😀", style: TextStyle(fontSize: 24),);
    }
  }
}

実装としては以上になります。最後までお読みいただきありがとうございました!

補足

本記事ではリクエストURLを以下として実装していました。

http://localhost:3000/api/login

なのでローカルでサーバーを起動せずにログインボタンを押した場合は、エラーハンドリングを全くしていないので、ローディングがくるくる出っ放しになるかと思います...。
自分は以下の記事を参考に、簡単なモックサーバーをたてて確認しました。