Flutterで簡易コーポレートサイト（アプリ）を作成する #2 -Contact画面編-

# 概要
トップページと同様にお問い合わせページをFlutterで再現していきます。
Contactコンポーネントを作成しバリデーションとAPI連携を実装していきます。

# 完成イメージ
<img src="https://dl.dropbox.com/s/d52hhiaubo7og2s/000010.jpg" width=300 alt="000020.jpg">
<img width="300" src="https://dl.dropbox.com/s/koo1sk1nyzwtj4s/000030.jpg" alt="000030.jpg">

# Contact画面作成
## ファイル作成
新しくmain.dart、top.dartと同様の階層に「contact.dart」を作成します。



## 入力フォーム作成（見た目だけ）
状態が変化するため、StatefulWidgetを利用します。
createStateメソッドをオーバーライドして、新しいウィジェットの状態クラスを作成します。
> 何も記入されていないcontact.dart上でstlと入力すると、statefull widgetの雛形が出てくるのでこちらを利用してもいいと思います。（nameの変更などしてください）

表示内容はContainer単位で見るとわかりやすいです。

```contact.dart
import 'package:flutter/material.dart';

class ContactPage extends StatefulWidget {
  const ContactPage({super.key});

  @override
  State<StatefulWidget> createState() => _ContactPageState();
}

class _ContactPageState extends State<ContactPage> {
  @override
  Widget build(BuildContext context) {
    return Form(
        child: Scaffold(
      body: Center(
        child: Column(
          children: <Widget>[
            Container(
              margin: const EdgeInsets.only(
                    top: 80,
                    left: 20,
                    right: 20,
                    bottom: 10,
              ),
              child: const Text(
                    'お問い合わせ',
                    textAlign: TextAlign.center,
                    style: TextStyle(fontWeight: FontWeight.bold),
              ),
            ),
            Container(
              margin: const EdgeInsets.only(
                left: 20,
                right: 20,
                top: 5,
                bottom: 5,
              ),
              child: const Align(
                  alignment: Alignment.centerLeft,
                  child: Text('お名前',
                      textAlign: TextAlign.left, style: TextStyle())),
            ),
            Container(
                margin: const EdgeInsets.only(
                  left: 20,
                  right: 20,
                ),
                child: TextFormField(
                  decoration: const InputDecoration(
                      hintText: "お名前",
                      filled: true,
                      border: InputBorder.none,
                      fillColor: Color.fromARGB(255, 240, 240, 240)),
                )),
            Container(
              margin: const EdgeInsets.only(
                left: 20,
                right: 20,
                top: 5,
                bottom: 5,
              ),
              child: const Align(
                  alignment: Alignment.centerLeft,
                  child: Text('Email',
                      textAlign: TextAlign.left, style: TextStyle())),
            ),
            Container(
                margin: const EdgeInsets.only(
                  left: 20,
                  right: 20,
                ),
                child: TextFormField(
                  decoration: const InputDecoration(
                      hintText: "Email",
                      filled: true,
                      border: InputBorder.none,
                      fillColor: Color.fromARGB(255, 240, 240, 240)),
                )),
            Container(
              margin: const EdgeInsets.only(
                left: 20,
                right: 20,
                top: 5,
                bottom: 5,
              ),
              child: const Align(
                  alignment: Alignment.centerLeft,
                  child: Text('お問合せ内容',
                      textAlign: TextAlign.left, style: TextStyle())),
            ),
            Container(
                margin: const EdgeInsets.only(
                  left: 20,
                  right: 20,
                  bottom: 20,
                ),
                child: TextFormField(
                  keyboardType: TextInputType.multiline,
                  maxLines: 4,
                  decoration: const InputDecoration(
                      hintText: "お問い合わせ内容",
                      filled: true,
                      border: InputBorder.none,
                      fillColor: Color.fromARGB(255, 240, 240, 240)),
                )),
            ColoredBox(
              color: const Color.fromARGB(255, 204, 204, 204),
              child: SizedBox(
                width: MediaQuery.of(context).size.width - 40,
                child: TextButton(
                  onPressed: () {},
                  child: const Text(
                    "送信",
                    style: TextStyle(color: Color.fromARGB(255, 0, 0, 0)),
                  ),
                ),
              ),
            ),
          ],
        ),
      ),
    ));
  }
}

```
状態を確認したいので、mainを編集してcontactを表示させてみます。

```diff_dart:main.dart
  import 'package:flutter/material.dart';

  import 'top.dart';
+ import 'contact.dart';

  void main() {
    runApp(const MyApp());
  }

  class MyApp extends StatelessWidget {
    const MyApp({super.key});

    // This widget is the root of your application.
    @override
    Widget build(BuildContext context) {
      return MaterialApp(
        title: 'Flutter Website',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
+        home: const ContactPage(),
-        home: const TopPage(),
      );
    }
  }

```

この状態で確認すると、このような感じです。
<img width=300 src="https://dl.dropbox.com/s/d52hhiaubo7og2s/000010.jpg" alt="000010.jpg">
## バリデーションの追加
FormウィジェットとGlobalKey<FormState>を使用して、フォームの状態を管理していきます。
_formKeyはフォームの状態を管理するためのグローバルキーです。
validateメソッドを呼び出すことでフォームの検証を行えます。

```diff_dart:contact.dart
  import 'package:flutter/material.dart';

  class ContactPage extends StatefulWidget {
    const ContactPage({super.key});

    @override
    State<StatefulWidget> createState() => _ContactPageState();
  }

  class _ContactPageState extends State<ContactPage> {
+   final _formKey = GlobalKey<FormState>();

    @override
    Widget build(BuildContext context) {
      return Form(
+         key: _formKey,
          child: Scaffold(
```



### お名前
お名前について編集を追加していきます。

```diff_dart:contact.dart
  Container(
    margin: const EdgeInsets.only(
      left: 20,
      right: 20,
    ),
    child: TextFormField(
+     validator: (value) {
+       if (value == null || value.isEmpty) {
+         return "お名前は必須です。";
+       }
+       return null;
+     },
      decoration: const InputDecoration(
          hintText: "お名前",
          filled: true,
          border: InputBorder.none,
          fillColor: Color.fromARGB(255, 240, 240, 240)),
    ),
  ),
```

```dart:コピペ用
validator: (value) {
  if (value == null || value.isEmpty) {
    return "お名前は必須です。";
  }
  return null;
},
```

### email
emailについて編集を追加していきます。

```diff_dart:contact.dart
  child: TextFormField(
+   validator: (value) {
+     if (value == null ||
+         value.isEmpty ||
+         !RegExp(r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9]+\.[a-zA-Z]+")
+             .hasMatch(value)) {
+       return "emailは必須かつemailの形式で入力してください。";
+     }
+     return null;
+   },
    decoration: const InputDecoration(
        hintText: "Email",
        filled: true,
        border: InputBorder.none,
        fillColor: Color.fromARGB(255, 240, 240, 240)),
  ),
```

```dart:コピペ用
validator: (value) {
  if (value == null ||
      value.isEmpty ||
      !RegExp(r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9]+\.[a-zA-Z]+")
          .hasMatch(value)) {
    return "emailは必須かつemailの形式で入力してください。";
  }
  return null;
},
```

### お問い合わせ内容
問い合わせ内容についても同様に追加していきます。

```diff_dart:contact.dart
  child: TextFormField(
+     validator: (value) {
+       if (value == null || value.isEmpty || value.length > 10) {
+         return "お問合せ内容は必須かつ1文字以上10文字以下で入力してください。";
+       }
+       return null;
+     },
      keyboardType: TextInputType.multiline,
      maxLines: 4,
      decoration: const InputDecoration(
        hintText: "お問い合わせ内容",
        filled: true,
        border: InputBorder.none,
        fillColor: Color.fromARGB(255, 240, 240, 240),
      ),
    ),
  ),

```

```dart:コピペ用
validator: (value) {
  if (value == null || value.isEmpty || value.length > 10) {
    return "お問合せ内容は必須かつ1文字以上10文字以下で入力してください。";
  }
  return null;
},
```


## ボタンをクリックしたときの動作を設定
`FocusScope.of(context).unfocus();`はキーボードを閉じる動作を追加しています。

```diff_dart:contact.dart
  child: TextButton(
    onPressed: () {
+      FocusScope.of(context).unfocus();
+      if (_formKey.currentState!.validate()) {
+          //Data valid
+      } else {
+        //Data invalid
+      }
    },
    child: const Text(
      "送信",
      style: TextStyle(color: Color.fromARGB(255, 0, 0, 0)),
    ),
  ),
```
```dart:コピペ用
FocusScope.of(context).unfocus();
if (_formKey.currentState!.validate()) {
    //Data valid
} else {
  //Data invalid
}
```

確認すると、エラーが出る状態で送信ボタンをクリックするとエラーメッセージが見えます。
<img src="https://dl.dropbox.com/s/njhsrm1bl66hx8z/000001.jpg" width=300 alt="000001.jpg">

# API連携

送信ボタンを押したときの動作を設定していきます。
## 値のハンドリング
TextEditingControllerを使って、それぞれの値の状態を管理できるようにします。
また、値の破棄の記述もしておきます。

```diff_dart:contact.dart
  class _ContactPageState extends State<ContactPage> {
    final _formKey = GlobalKey<FormState>();

+   final nameController = TextEditingController();
+   final emailController = TextEditingController();
+   final contentController = TextEditingController();

+   @override
+   void dispose() {
+     nameController.dispose();
+     super.dispose();
+   }
```

```dart:コピペ用
final nameController = TextEditingController();
final emailController = TextEditingController();
final contentController = TextEditingController();

@override
 void dispose() {
  nameController.dispose();
  super.dispose();
}
```

それぞれのフォームにcontrollerを追加します。

```diff_dart:contact.dart

  Container(
    margin: const EdgeInsets.only(
      left: 20,
      right: 20,
    ),
    child: TextFormField(
      validator: (value) {
        if (value == null || value.isEmpty) {
          return "お名前は必須です。";
        }
        return null;
      },
+     controller: nameController,
      decoration: const InputDecoration(
          hintText: "お名前",
          filled: true,
          border: InputBorder.none,
          fillColor: Color.fromARGB(255, 240, 240, 240)),
    ),
  ),
```

```diff_dart:contact.dart
  Container(
    margin: const EdgeInsets.only(
      left: 20,
      right: 20,
    ),
    child: TextFormField(
      validator: (value) {
        if (value == null ||
            value.isEmpty ||
            !RegExp(r"^[a-zA-Z0-9.a-zA-Z0-9.!#$%&'*+-/=?^_`{|}~]+@[a-zA-Z0-9]+\.[a-zA-Z]+")
                .hasMatch(value)) {
          return "emailは必須かつemailの形式で入力してください。";
        }

        return null;
      },
+     controller: emailController,
      decoration: const InputDecoration(
          hintText: "Email",
          filled: true,
          border: InputBorder.none,
          fillColor: Color.fromARGB(255, 240, 240, 240)),
    ),
  ),
```

```diff_dart:contact.dart
  Container(
    margin: const EdgeInsets.only(
      left: 20,
      right: 20,
      bottom: 20,
    ),
    child: TextFormField(
      validator: (value) {
        if (value == null || value.isEmpty || value.length > 10) {
          return "お問合せ内容は必須かつ1文字以上10文字以下で入力してください。";
        }
        return null;
      },
+     controller: contentController,
      keyboardType: TextInputType.multiline,
      maxLines: 4,
      decoration: const InputDecoration(
        hintText: "お問い合わせ内容",
        filled: true,
        border: InputBorder.none,
        fillColor: Color.fromARGB(255, 240, 240, 240),
      ),
    ),
  ),
```

## APIを叩く


## httpパッケージ追加
http通信できるようにするためにパッケージを追加します。
pubspec.yamlを更新します。

```diff_yaml:pubspec.yaml
  dependencies:
+   http: any
    flutter:
      sdk: flutter
```

## http.clientをcontact.dartでインポート
http.clientをインポートします。

```diff_dart:contact.dart
  import 'package:flutter/material.dart';
+ import 'package:http/http.dart' as http;
+ import 'dart:convert';
```
送信ボタンをクリックしたらAPIを叩くようにしていきます。
返ってきたメッセージを一旦コンソールに表示させてみます。

:::note warn
Flutter内でGoogleAppsScriptを叩くと必ずリダイレクトが発生します。（GASの仕様）
今回のAPIはGASで実装をしているため、ステータスコード302が返って来るのでリダイレクト先でレスポンスを取得する処理が必要となっていますが、GAS以外で実装したAPIの場合はリダイレクトの処理は不要となる場合があります。
:::

```diff_dart:contact.dart
  ColoredBox(
    color: const Color.fromARGB(255, 204, 204, 204),
    child: SizedBox(
      width: MediaQuery.of(context).size.width - 40,
      child: TextButton(
-       onPressed: ()  {
+       onPressed: () async {
          FocusScope.of(context).unfocus();
          if (_formKey.currentState!.validate()) {
+           String url ="API_URL";
+           final client = http.Client();
+
+           http.Response response = await client
+               .post(Uri.parse(url), headers: <String, String>{
+             'Content-Type': 'application/x-www-form-urlencoded'
+           }, body: {
+             "name": nameController.text,
+             "email": emailController.text,
+             "body": contentController.text
+           });
+           if (response.statusCode == 302) {
+             String redirecturl = response.headers['location']!;
+             var res = await client.get(Uri.parse(redirecturl));
+             var data = jsonDecode(res.body);
+             print(data["message"]);
+             client.close();
            }
            
          } else {
            //Data invalid
          }
        },
        child: const Text(
          "送信",
          style: TextStyle(color: Color.fromARGB(255, 0, 0, 0)),
        ),
      ),
    ),
  ),
```

```dart:コピペ用
  onPressed: () async {
    FocusScope.of(context).unfocus();
    if (_formKey.currentState!.validate()) {
      String url =
          "https://script.google.com/macros/s/{デプロイID}/exec";
      final client = http.Client();

      http.Response response = await client
          .post(Uri.parse(url), headers: <String, String>{
        'Content-Type': 'application/x-www-form-urlencoded'
      }, body: {
        "name": nameController.text,
        "email": emailController.text,
        "body": contentController.text
      });

      if (response.statusCode == 302) {
        String redirecturl = response.headers['location']!;
        var res = await client.get(Uri.parse(redirecturl));
        var data = jsonDecode(res.body);
        print(data["message"]);
        client.close();
      }
    } else {
      //Data invalid
    }
  },
```

確認してみます。
デバッグコンソールに「success!」と返ってきているのでAPIを叩いて戻り値も取得できているようです。
<img src="https://dl.dropbox.com/s/9laiq71jhsdcgvp/000020.jpg" alt="000020.jpg"/>

## ダイアログ作成
ダイアログで戻り値を表示できるようにしていきます。
### showAlertDialogの作成
表示したいダイアログを作成して、ダイアログを表示する機能を作成します。
contact.dartの一番下に追加します。

```contact.dart
showAlertDialog(BuildContext context, String title, String message) {
  //ダイアログのボタンの設定
  Widget okButton = TextButton(
    child: const Text("OK"),
    onPressed: () {
      Navigator.of(context).pop();
    },
  );

  // ダイアログ設定
  AlertDialog alert = AlertDialog(
    title: Text(title),
    content: Text(message),
    actions: [
      okButton,
    ],
  );

  // ダイアログ表示
  showDialog(
    context: context,
    builder: (BuildContext context) {
      return alert;
    },
  );
}
```

### 設定
printで表示していた部分をShowAlertDialogに変更します。

```diff_dart:contact.dart
  if (response.statusCode == 302) {
    String redirecturl = response.headers['location']!;
    var res = await client.get(Uri.parse(redirecturl));
    var data = jsonDecode(res.body);
-   print(data["message"]);
+   showAlertDialog(context, "", data["message"]);
    client.close();
  }
```



# 確認
確認するとこのようにレスポンスが返ってきているのが確認できます。
<img width="300" src="https://dl.dropbox.com/s/koo1sk1nyzwtj4s/000030.jpg" alt="000030.jpg">

# Androidでビルドする場合
Androidでビルドする場合は「AndroidManifest.xml」に下記を追加します。
android > app > src > profile > AndroidManifest.xmlです。

```AndroidManifest.xml
<uses-permission android:name="android.permission.INTERNET" />
```

# 関連コンテンツ
- 次：[Flutterで簡易コーポレートサイト（アプリ）を作成する #3 -タブバー編-](https://qiita.com/minimumskills/private/97a1c7c35b3bbc7ce8f1)
- 前：[Flutterで簡易コーポレートサイト（アプリ）を作成する #1 -TOP画面編-](https://qiita.com/minimumskills/private/8d329c332c0ce76347a3)
- [超シンプルなWeb（アプリ）作成を通して各種フロントエンド技術を体験する - 2023 Early -](https://qiita.com/minimumskills/private/9e7efe86e244bb9844b0)