Flutterの状態管理ProviderでREST APIを叩いて結果を表示する

flutterで状態管理をしたくて、Providerを使ってみました。

こんな感じで自分のアプリのグループを取得してみました
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/188304/9241fe92-71ea-3d84-2dce-7ebdebab6606.png)

# ApiClientを作る
別に作んなくてもできるんですけど、apiを呼ぶメゾットをまとめた`ApiClient`というクラスを作る。

```dart
import 'package:http/http.dart' as http;
import 'package:meta/meta.dart';
import 'package:skimer/exceptions.dart';
import 'package:firebase_auth/firebase_auth.dart';
import 'package:skimer/models/group.dart'; //自分で定義したモデル
import 'package:skimer/models/task.dart';//自分で定義したモデル
import 'package:skimer/models/user.dart' as models; //firebaseのユーザーとかぶるのでmodels.Userとする

class ApiClient {
  final _baseUrl = 'http://localhost:8000';
  final http.Client httpClient;

  ApiClient({
    @required this.httpClient,
  }) : assert(httpClient != null);

  Future<List<Task>> fetchTaskList(String id, User user) async {
    final String token = (await user.getIdToken()); // firbebaseのaccess token
    final url = '$_baseUrl/users/$id/tasks';
    final response = await this.httpClient.get(
      url,
      headers: {"Content-Type": "application/json",
      "Authorization": "Bearer $token"},
    );

    if (response.statusCode != 200) {
      print(response.body);
      throw ApiException(ApiExceptionMessage.FetchTaskListMessage);
    }

    final List<dynamic> json = jsonDecode(response.body);
    print(json);
    return json != null ? json.map((i) => Task.fromJson(i)).toList() : [];
  }

  Future<List<Group>> fetchGroupList(String id, User user) async {
    final String token = (await user.getIdToken()); // firbebaseのaccess token
    final url = '$_baseUrl/users/$id/groups';
    final response = await this.httpClient.get(url,headers: {"Content-Type": "application/json",
      "Authorization": "Bearer $token"},);

    if (response.statusCode != 200) {
      print(response.body);
      throw ApiException(ApiExceptionMessage.FetchGroupListMessage);
    }

    final List<dynamic> json = jsonDecode(response.body);
    print(json);
    return json != null ? json.map((i) => Group.fromJson(i)).toList() : [];
  }
```

## ApiExceptionの実装

API系のエラーの時に発生するApiExceptionというのも作ってみました。
`implements Exception`をしてExceptionインターフェースを継承することができます。
`toUserFriendlyMessage()`でわかりやすい日本語のエラーメッセージにする関数を付けました。

```dart
import 'package:skimer/commons/error_messages.dart';

class ApiException implements Exception {
  final String msg;

  const ApiException(this.msg);

  String toString() => 'ApiException: $msg';

  String toUserFriendlyMessage() {
    switch (msg) {
      case ApiExceptionMessage.fetchTaskListMessage:
        return CommonErrorMessage.fetchTaskListMessage;
      case ApiExceptionMessage.fetchGroupListMessage:
        return CommonErrorMessage.fetchGroupListMessage;
      case ApiExceptionMessage.fetchTimelineMessage:
        return CommonErrorMessage.fetchTimelineMessage;
      case ApiExceptionMessage.fetchTaskListInGroupMessage:
        return CommonErrorMessage.fetchTaskListInGroupMessage;
      case ApiExceptionMessage.fetchRankingInGroupMessage:
        return CommonErrorMessage.fetchRankingInGroupMessage;
      case ApiExceptionMessage.fetchTimelineInGroupMessage:
        return CommonErrorMessage.fetchTimelineInGroupMessage;
      case ApiExceptionMessage.fetchUserMessage:
        return CommonErrorMessage.fetchUserMessage;
      case ApiExceptionMessage.joinGroupMessage:
        return CommonErrorMessage.joinGroupMessage;
      default:
        return CommonErrorMessage.undefinedMessage;
    }
  }
}

class ApiExceptionMessage {
  static const fetchTimelineMessage = 'Failed to fetch timeline';
  static const fetchGroupListMessage = 'Failed to fetch groups';
  static const fetchTaskListMessage = 'Failed to fetch tasks';
  static const fetchTaskListInGroupMessage =
      'Failed to fetch tasks in a paticular group';
  static const fetchTimelineInGroupMessage =
      'Failed to fetch timeline in a paticular group';
  static const fetchRankingInGroupMessage =
      'Failed to fetch ranking in a paticular group';
  static const fetchUserMessage = 'Failed to fetch user';
  static const joinGroupMessage = 'Failed to join the group';
}

```


# ChangeNotifierを適用したクラスを作る。

ChangeNotifierを適用したクラスを作ることで、`notifyListeners();`を呼んだ時、Widgetが再描写されるようになります。
`groupList`はグループリストを保管し、_messageはエラーメッセージ、statusは`enum FetchStatus { Loading, Fetched, Failed }`

```dart
import 'package:flutter/widgets.dart';
import 'package:skimer/commons/error_messages.dart';
import 'package:skimer/exceptions.dart';
import 'package:skimer/models/group.dart';
import 'package:skimer/providers/task_list_provider.dart';
import 'package:skimer/repositories/group_repository.dart';
import 'package:logger/logger.dart';
import 'package:firebase_auth/firebase_auth.dart';

enum FetchStatus { Loading, Fetched, Failed }

var logger = Logger(
  printer: PrettyPrinter(),
);

class GroupListProvider with ChangeNotifier {
  FetchStatus _status = FetchStatus.Loading;
  final GroupRepository repository; // ApiClientをさらにラップしたもの
  List<Group> _groupList = [];
  String _message;

  FetchStatus get status => _status;
  List<Group> get groupList => _groupList;
  String get message => _message;


  GroupListProvider({@required this.repository}) : assert(repository != null);

  fetchGroupList(String id, User user) async {
    _status = FetchStatus.Fetched;
    try {
      _groupList = await repository.fetchGroupList(id, user);
    } on ApiException catch (e) {
      logger.e(e);
      _status = FetchStatus.Failed;
      _message = e.toUserFriendlyMessage();
    } catch (e) {
      logger.e(e);
      _status = FetchStatus.Failed;
      _message = CommonErrorMessage.undefinedMessage;
    }
    notifyListeners();

  }
}

```


# ビュー
ビューでは、`groupListProvider`の状態に合わせてウィジットを振り分ける。

```dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:skimer/commons/colors.dart';
import 'package:skimer/commons/labels.dart';
import 'package:skimer/commons/utils.dart';
import 'package:skimer/components/custom_bottom_navigation_bar.dart';
import 'package:skimer/components/group_component.dart';
import 'package:skimer/providers/group_list_provider.dart';![スクリーンショット 2020-12-31 18.09.14.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/188304/394627cd-91be-ad66-d041-c4184c2a7280.png)

import 'package:skimer/providers/task_list_provider.dart';
import 'package:skimer/providers/user_provider.dart';
import 'package:skimer/screens/join_group_screen.dart';

class GroupListScreen extends StatelessWidget {
  final _scaffoldKey = GlobalKey<ScaffoldState>();

  @override
  Widget build(BuildContext context) {
    final groupListProvider = context.watch<GroupListProvider>();
    if (groupListProvider.status == FetchStatus.Failed) { // もし失敗していたらエラーメッセージを表示
      WidgetsBinding.instance.addPostFrameCallback((_) => _scaffoldKey
          .currentState
          .showSnackBar(SnackBar(content: Text((groupListProvider.message)))));
    }
    return Scaffold(
      key: _scaffoldKey,
      appBar: AppBar(
        elevation: 0,
        backgroundColor: CommonColor.appBarColor,
        title: createHeaderText(CommonLabel.groupLabel),  
      ),
      body: RefreshIndicator(
        onRefresh: () async {
          final user = context.read<UserProvider>();
          final uid = user.firebaseUser.uid;
          final firebaseUser = user.firebaseUser;
          context.read<GroupListProvider>().fetchGroupList(uid, firebaseUser);
        },
        child: SingleChildScrollView(
          physics: AlwaysScrollableScrollPhysics(),
          child: groupListProvider.status == FetchStatus.Loading // FetchStatusで状態確認
                  ? CircularProgressIndicator()
                  : Container(
                      height: MediaQuery.of(context).size.height,
                      child: groupListProvider.groupList.length != 0
                          ? ListView(
                              padding: EdgeInsets.only(
                                  top: 10, bottom: 10, left: 10),
                              children: groupListProvider.groupList
                                  .map((i) => GroupComponent(i))
                                  .toList(),
                            )
                          : Center(
                              child: Column(
                                mainAxisSize: MainAxisSize.min,
                                children: [
                                  Text("グループに参加していません"),
                                  RaisedButton(
                                      color: Colors.white,
                                      onPressed: () {
                                        Navigator.push(
                                            context,
                                            MaterialPageRoute(
                                                builder: (context) =>
                                                    JoinGroupScreen()));
                                      },
                                      child: Text("グループに参加する"))
                                ],
                              ),
                            ),
                    ),
        ),
      ),
      bottomNavigationBar: CustomNavigationBar(),
    );
  }
}
```