FlutterアプリをCleanArchitecture + TDDで書く【最終回】(UI実装)
今回で最終回です。ここまでお読みいただいた方は本当にありがとうございます。
タイトルに"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
/domain
はlib
直下に移動してみました。また、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());
}
UserLoginModel
はprovider
パッケージにより生成するタイミングにあわせて生成するので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
なのでローカルでサーバーを起動せずにログインボタンを押した場合は、エラーハンドリングを全くしていないので、ローディングがくるくる出っ放しになるかと思います...。
自分は以下の記事を参考に、簡単なモックサーバーをたてて確認しました。
Discussion