LaravelとFlutterで認証機能を持ったアプリを作る　②FlutterアプリからLaravelへリクエストを送信する

## 全体概要
①[Laravel Passportを使ってAPI認証を実装する](https://qiita.com/atm_33/items/31d5d8dad28fd267c32c)
②FlutterアプリからLaravelへリクエストを送信する（本記事）


## 前回の振り返り

前回はLaravel Passportを使ってAPI認証できるようにLaravel側での実装を行いました。

下記全体の流れの中でいえば2までが完了した状態です。

1. Flutterアプリからログインまたは会員登録APIを叩く
2. Laravel側でユーザー情報を取得。その時に認証トークンを作成してFlutterアプリに返却
3. Flutterアプリ側で受け取った認証トークンをShared Preferenceに保存。以降Laravelへリクエストを送信する際はヘッダーに保存した認証トークン情報を追加してリクエスト
4. 認証トークンがヘッダーに含まれている場合はAuth::user()等の関数でユーザー情報が取得できるのでLarave側で認証に関する処理を書くことができるようになる

本記事では3,4について説明していきたいと思います。

## Flutterプロジェクトの作成

それでは早速Flutterプロジェクトを作成して実装に入っていきましょう。
Android Studioでプロジェクトを作成した後は下記必要パッケージを追加します。

```
$ flutter pub add shared_preferences
$ flutter pub add http
$ flutter pub get
```

```yaml:pubspec.yaml
dependencies:
  shared_preferences: ^2.0.15
  http: ^0.13.5
```


## Flutterアプリの実装

必要パッケージを追加したら早速Flutter側の実装を書いていきましょう。

今回はAPI通信に関する処理を一つのクラスにまとめることにします。
また確認用にトップページ・ログインページ・会員登録ページを作るので、ディレクトリ構造は下記のような形になります。

```
$ tree -L 3
.
├── README.md
├── analysis_options.yaml
├── android
|    ・・・省略・・・
├── build
|    ・・・省略・・・
├── flutter_app.iml
├── ios
|    ・・・省略・・・
├── lib
│   ├── main.dart
│   ├── pages
│   │   ├── home.dart // トップページ
│   │   ├── login.dart // ログインページ
│   │   └── register.dart // 会員登録ページ
│   └── utils
│       └── network.dart // API通信に関する処理
├── pubspec.lock
├── pubspec.yaml
└── test
    └── widget_test.dart
```

### API通信に関する処理

まずはAPI通信に関する処理を一つにまとめたクラスについて実装していきます。

このクラスのやりたいことは**簡単にPOSTやGETでLaravelにAPIを投げることができるようにする**ことです。


```dart:lib/utils/network.dart
import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:http/http.dart';

class Network {
  // Androidシュミレーターを使う場合はlocalhostを10.0.2.2に変更する
  final String _url = 'http://localhost/api';
  String? token;

  // SharedPreferencesからトークンを取得
  _setToken() async {
    SharedPreferences localStorage = await SharedPreferences.getInstance();
    String? localToken = localStorage.getString('token');
    
    // なぜかlocalStorageから取得した値の前後に"が入るので仕方なくここで置換する
    if (localToken != null) {
      token = localToken.replaceAll('"', '');
    }
  }

  // ヘッダー情報をセット
  _getHeaders() => {
        'Content-type': 'application/json',
        'Accept': 'application/json',
        'Authorization': 'Bearer $token'
      };

  // POST
  Future<Response> postData(data, String apiUrl) async {
    await _setToken();
    Uri fullUrl = Uri.parse(_url + apiUrl);
    return await post(fullUrl, body: jsonEncode(data), headers: _getHeaders());
  }

  // GET
  Future<Response> getData(String apiUrl) async {
    await _setToken();
    Uri fullUrl = Uri.parse(_url + apiUrl);
    Response res = await get(fullUrl, headers: _getHeaders());    
    return res;
  }
}

```


注目するところは`_getHeaders`に`$token`が含まれている部分です。

POSTやGETで通信する時のヘッダーにLaravel側で生成したトークン情報を入れることで、Laravel側で認証に関する処理（Auth::user()など）が書けるようになります。

またトークン情報は今回はSharedPreferencesに入れることとしました。その結果なぜかlocalStorageから取得した値に"が前後についてしまったので、少しダサいですがreplaceAllでtokenから"を削除しています。

このクラスでAPI連携するキモの部分が全て記載されている形になります。

### ログイン画面

次にログイン画面・会員登録画面・トップ画面の実装になりますが、Networkクラスを使ってAPIを投げているだけなので特に難しいことはしていません。

なので簡単にコードと画面のスクリーンショットだけ載せておきますが、自前で実装する時は特に参考にしなくていいかなと思います。

<img width="300px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2724713/6ee06673-840b-f047-ea84-73bc282f81ca.png"/>

```dart:lib/pages/login.dart
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter_app/pages/home.dart';
import 'package:flutter_app/pages/register.dart';
import 'package:flutter_app/utils/network.dart';
import 'package:http/http.dart';
import 'package:shared_preferences/shared_preferences.dart';

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

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

class LoginState extends State<Login> {
  final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
  bool _isLoading = false;
  String? _email;
  String? _password;

  Future<void> _login() async {
    if (!_formKey.currentState!.validate()) {
      return;
    }

    setState(() {
      _isLoading = true;
    });

    Map<String, String> data = {'email': _email!, 'password': _password!};

    Response? res;
    try {
      res = await Network().postData(data, '/login');
    } catch (e) {
      debugPrint(e.toString());
    }

    if (res == null) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text("エラーが発生しました。")),
        );
      }
      setState(() {
        _isLoading = false;
      });
      return;
    }

    var body = json.decode(res.body);

    // エラーの場合
    if (res.statusCode != 200) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text(body['message'])),
        );
      }
      setState(() {
        _isLoading = false;
      });
      return;
    }

    // 正常終了の場合
    SharedPreferences localStorage = await SharedPreferences.getInstance();
    localStorage.setString('token', json.encode(body['token']));
    localStorage.setString('user', json.encode(body['user']));

    if (!mounted) return;
    Navigator.push(
      context,
      MaterialPageRoute(builder: (context) => const Home()),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          centerTitle: true,
          title: const Text("ログイン"),
        ),
        body: SafeArea(
            child: _isLoading
                ? const Center(
                    child: CircularProgressIndicator(),
                  )
                : Form(
                    key: _formKey,
                    child: Container(
                      padding: const EdgeInsets.all(16),
                      child: Column(
                        children: [
                          TextFormField(
                            keyboardType: TextInputType.text,
                            decoration: const InputDecoration(
                              hintText: "メールアドレス",
                            ),
                            validator: (emailValue) {
                              if (emailValue == null || emailValue == "") {
                                return 'メールアドレスは必ず入力してください。';
                              }
                              _email = emailValue;
                              return null;
                            },
                          ),
                          TextFormField(
                            keyboardType: TextInputType.text,
                            decoration: const InputDecoration(
                              hintText: "パスワード",
                            ),
                            obscureText: true,
                            validator: (passwordValue) {
                              if (passwordValue == null ||
                                  passwordValue == "") {
                                return 'パスワードは必ず入力してください。';
                              }
                              _password = passwordValue;
                              return null;
                            },
                          ),
                          const SizedBox(
                            height: 32,
                          ),
                          ElevatedButton(
                              onPressed: () {
                                _login();
                              },
                              child: const Text("ログイン")),
                          const SizedBox(
                            height: 16,
                          ),
                          ElevatedButton(
                              onPressed: () {
                                Navigator.push(
                                  context,
                                  MaterialPageRoute(
                                      builder: (context) => const Register()),
                                );
                              },
                              child: const Text("会員登録")),
                        ],
                      ),
                    ))));
  }
}

```

### 会員登録画面

<img width="300px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2724713/cbc8fdbc-bc40-64b3-033c-8dde16816c00.png">

```dart:lib/pages/register.dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_app/pages/home.dart';
import 'package:flutter_app/utils/network.dart';
import 'package:http/http.dart';
import 'package:shared_preferences/shared_preferences.dart';

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

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

class RegisterState extends State<Register> {
  final GlobalKey<FormState> _formKey = GlobalKey<FormState>();
  bool _isLoading = false;
  String? _name;
  String? _email;
  String? _password;

  Future<void> _register() async {
    if (!_formKey.currentState!.validate()) {
      return;
    }

    setState(() {
      _isLoading = true;
    });

    Map<String, String> data = {
      'name': _name!,
      'email': _email!,
      'password': _password!
    };

    Response? res;
    try {
      res = await Network().postData(data, '/register');
    } catch (e) {
      debugPrint(e.toString());
    }

    if (res == null) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(content: Text("エラーが発生しました。")),
        );
      }
      setState(() {
        _isLoading = false;
      });
      return;
    }

    var body = json.decode(res.body);

    // エラーの場合
    if (res.statusCode != 200) {
      if (mounted) {
        ScaffoldMessenger.of(context).showSnackBar(
          SnackBar(content: Text(body['message'])),
        );
      }
      setState(() {
        _isLoading = false;
      });
      return;
    }

    // 会員登録成功の場合
    SharedPreferences localStorage = await SharedPreferences.getInstance();
    localStorage.setString('token', json.encode(body['token']));
    localStorage.setString('user', json.encode(body['user']));

    if (!mounted) return;
    Navigator.push(
      context,
      MaterialPageRoute(builder: (context) => const Home()),
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          centerTitle: true,
          title: const Text("会員登録"),
        ),
        body: SafeArea(
            child: _isLoading
                ? const Center(
                    child: CircularProgressIndicator(),
                  )
                : Form(
                    key: _formKey,
                    child: Container(
                      padding: const EdgeInsets.all(16),
                      child: Column(
                        children: [
                          TextFormField(
                            keyboardType: TextInputType.text,
                            decoration: const InputDecoration(
                              hintText: "名前",
                            ),
                            validator: (nameValue) {
                              if (nameValue == null || nameValue == "") {
                                return '名前は必ず入力してください。';
                              }
                              _name = nameValue;
                              return null;
                            },
                          ),
                          TextFormField(
                            keyboardType: TextInputType.text,
                            decoration: const InputDecoration(
                              hintText: "メールアドレス",
                            ),
                            validator: (emailValue) {
                              if (emailValue == null || emailValue == "") {
                                return 'メールアドレスは必ず入力してください。';
                              }
                              _email = emailValue;
                              return null;
                            },
                          ),
                          TextFormField(
                            keyboardType: TextInputType.text,
                            decoration: const InputDecoration(
                              hintText: "パスワード",
                            ),
                            obscureText: true,
                            validator: (passwordValue) {
                              if (passwordValue == null ||
                                  passwordValue == "") {
                                return 'パスワードは必ず入力してください。';
                              }
                              _password = passwordValue;
                              return null;
                            },
                          ),
                          const SizedBox(
                            height: 32,
                          ),
                          ElevatedButton(
                              onPressed: () {
                                _register();
                              },
                              child: const Text("会員登録"))
                        ],
                      ),
                    ))));
  }
}

```
 


### トップ画面（ログイン後の画面）


<img width="300px" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2724713/e4e3b8de-65dd-e233-ea48-fc24750421bd.png">

```dart:lib/pages/home.dart
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter_app/pages/login.dart';
import 'package:flutter_app/utils/network.dart';
import 'package:http/http.dart';
import 'package:shared_preferences/shared_preferences.dart';

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

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

class HomeState extends State<Home> {
  String? _name;
  String? _email;
  bool _isLoading = false;

  @override
  void initState() {
    _loadUserData();
    super.initState();
  }

  _loadUserData() async {
    SharedPreferences localStorage = await SharedPreferences.getInstance();
    var user = jsonDecode(localStorage.getString('user')!);

    if (user != null) {
      setState(() {
        _name = user['name'];
        _email = user['email'];
      });
    }
  }

  @override
  Widget build(BuildContext context) {
    Future<void> _logout() async {
      setState(() {
        _isLoading = true;
      });

      Response? res;
      try {
        res = await Network().getData('/logout');
      } catch (e) {
        debugPrint(e.toString());
      }
      if (res == null) {
        if (mounted) {
          ScaffoldMessenger.of(context).showSnackBar(
            const SnackBar(content: Text("エラーが発生しました。")),
          );
        }
        setState(() {
          _isLoading = false;
        });
        return;
      }

      var body = json.decode(res.body);

      if (res.statusCode != 200) {
        if (mounted) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(body['message'])),
          );
        }
        setState(() {
          _isLoading = false;
        });
        return;
      }

      SharedPreferences localStorage = await SharedPreferences.getInstance();
      localStorage.remove('user');
      localStorage.remove('token');

      if (!mounted) return;
      Navigator.push(
          context, MaterialPageRoute(builder: (context) => const Login()));
    }

    return Scaffold(
        appBar: AppBar(
          centerTitle: true,
          title: const Text("ホーム"),
        ),
        body: SafeArea(
            child: _isLoading
                ? const Center(
                    child: CircularProgressIndicator(),
                  )
                : Center(
                    child: Column(
                      children: [
                        const SizedBox(
                          height: 32,
                        ),
                        const Text("ログインに成功しました！"),
                        const SizedBox(
                          height: 32,
                        ),
                        const Text("名前"),
                        Text(_name ?? ""),
                        const SizedBox(
                          height: 16,
                        ),
                        const Text("メールアドレス"),
                        Text(_email ?? ""),
                        const SizedBox(
                          height: 32,
                        ),
                        ElevatedButton(
                            onPressed: () {
                              _logout();
                            },
                            child: const Text("ログアウト"))
                      ],
                    ),
                  )));
  }
}

```


## 完成！！

これで会員登録・ログイン・ログアウトだけができる簡単なアプリが作成できたと思います。

振り返ってみるとLaravel Passportの導入と、ヘッダーにLaravelで発行されたトークンを入れてねくらいしか注意するところはなく、少し冗長な説明になってしまったかもしれませんね、、、

まあでもこれだけ書いておけば少なくとも自分は次回以降困らずにすみそうです。

この記事が誰かの役立てばいいなと思います。何か不明点あればコメントいただけると嬉しいです。

ありがとうございました！