Flutterで郵便番号検索アプリを作成する【Android Studio】

# はじめに
Flutter学習のため、Android Studioを使用して簡易的な郵便番号検索アプリを作成しました。

# 環境
Windows11

# 事前準備
### Flutter・Android Studioの準備
[公式ドキュメント](https://docs.flutter.dev/get-started/install/windows)に則ってインストールします。

https://docs.flutter.dev/get-started/install/windows

インストール自体はそこまで難しくありませんが、Android Studioのインストール中に「HAXMのインストール失敗」という旨のエラーが出ました。
こちらは下記のページを元にWHPXという機能を有効化することで解決しました。

https://learn.microsoft.com/ja-jp/xamarin/android/get-started/installation/android-emulator/hardware-acceleration?pivots=windows

### 公式のチュートリアル
実装に入る前に、まずはFlutter公式ドキュメントのチュートリアルで基本的なことを少し練習しました。
数が多いので全部はやっていませんが、基本的な部分と作成するアプリに必要な機能（httpリクエストやフォーム入力など）の部分をチュートリアルで学びました。

* [Building layouts](https://docs.flutter.dev/ui/layout/tutorial)
* [Navigate to a new screen and back](https://docs.flutter.dev/cookbook/navigation/navigation-basics)
* [Build a form with validation](https://docs.flutter.dev/cookbook/forms/validation)
* [Fetch data from the internet](https://docs.flutter.dev/cookbook/networking/fetch-data)

# 使用API
以下の郵便番号検索APIを使用しました。
フォームからユーザーが入力した郵便番号をパラメータにしてAPIを叩きます。
今回は取得できる情報のうち`都道府県名`、`市区町村名`、`町域名`を使用します。

http://zipcloud.ibsnet.co.jp/doc/api

# 実装
今回の主目的はFlutterでHTTPリクエストを行う練習になります。
UIを凝り始めると凄まじく時間がかかりそうだったので、基本的にはFlutterのサンプルコード（プロジェクトを作成すると最初から書かれているカウンターのアプリ）を編集して実装しました。

Riverpodなどの状態管理ライブラリは使用せず、StatelessWidgetとStatefulWidgetを使用して実装しています。

ソースは以下の4つに分けました。
* `main.dart`
    * 検索したい郵便番号の入力画面と検索結果表示画面を定義する
* `form.dart`
    * 入力フォーム部分の定義をする。結果表示画面を呼び出して受け取った入力値を渡す役目がある
* `request.dart`
    * HTTPリクエストを行う関数を定義する
* `address.dart`
    * JSONレスポンスを解析し、結果を格納するためのクラスと、結果を表形式またはテキスト（検索結果なしの場合のみ）で表示するウィジェットを返す

### main
二つの画面を定義しています。
`ResultPage`は入力値を受け取ってリクエストの結果を表示します。

<details><summary>main.dart</summary>

```dart
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'address_data.dart';
import 'request.dart';
import 'form.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: '郵便番号検索',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
        useMaterial3: true,
      ),
      home: const MyHomePage(title: '郵便番号検索'),
    );
  }
}

class MyHomePage extends StatelessWidget {
  const MyHomePage({super.key, required this.title});

  final String title;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          backgroundColor: Theme
              .of(context)
              .colorScheme
              .inversePrimary,
          title: Text(title),
        ),
        body: const ZipCodeForm()
    );
  }
}

class ResultPage extends StatefulWidget{
  const ResultPage({super.key, required this.imputCode});

  final int imputCode;

  @override
  State<ResultPage> createState() => _ResultPageState();
}

class _ResultPageState extends State<ResultPage>{
  late Future<http.Response> futureAddress;

  final String uri = 'https://zipcloud.ibsnet.co.jp/api/search?zipcode=';

  @override
  void initState(){
    super.initState();
    futureAddress = fetchResult('$uri${widget.imputCode}');
  }

  @override
  Widget build(BuildContext context){
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('Result'),
      ),
      body: Container(
        padding: const EdgeInsets.all(20),
        child: Center(
          child: Column(
            children: [
              FutureBuilder<http.Response>(
                future: futureAddress,
                builder: (context, snapshot){
                  if(snapshot.hasData){
                    final addressData = AddressData.fromJson(jsonDecode(snapshot.data!.body));
                    return addressData.formatResult();
                  } else if(snapshot.hasError){
                    return Text('${snapshot.error}');
                  }

                  return const CircularProgressIndicator();
                },
              ),
              ElevatedButton(
                onPressed: (){
                  Navigator.pop(context);
                },
                child: const Text('return home'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
```

</details>

### form
入力フォーム部分・結果表示画面の呼び出しをしています。
APIの仕様上はハイフン付きの郵便番号でも検索可能ですが、今回はハイフンなし半角数字7桁のみを受け入れるようにしています。

<details><summary>form.dart</summary>

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

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

  @override
  ZipCodeFormState createState(){
    return ZipCodeFormState();
  }
}

class ZipCodeFormState extends State<ZipCodeForm>{
  final _formKey = GlobalKey<FormState>();
  final _controller = TextEditingController();

  @override
  Widget build(BuildContext context){
    return Form(
      key: _formKey,
      child: Column(
        children: <Widget>[
          Padding(
            padding: const EdgeInsets.all(22),
            child: TextFormField(
              controller: _controller,
              validator: (value){
                if(value == null || value.isEmpty){
                  return '値を入力してください';
                } else if(int.tryParse(value) == null){
                  return '数値を入力してください';
                } else if(value.length != 7){
                  return '郵便番号はハイフンなし7桁で入力してください';
                }
                return null;
              },
            ),
          ),
          ElevatedButton(
              onPressed: (){
                if(_formKey.currentState!.validate()){
                  Navigator.push(
                      context,
                      MaterialPageRoute(builder: (context) => ResultPage(
                          imputCode: int.parse(_controller.text))
                      ));
                }
              },
              child: const Text('Submit')),
        ],
      ),
    );
  }
}
```
</details>

### request
URLを受け取り、GETリクエストを投げる関数を定義しています。

<details><summary>request.dart</summary>

```dart
import 'package:http/http.dart' as http;

Future<http.Response> fetchResult(String uri) async{
  final response = await http.
  get(Uri.parse(uri));

  if(response.statusCode == 200){
    return response;
  } else if(response.statusCode == 400) {
    throw Exception('Bad Request');
  } else if(response.statusCode == 500) {
    throw Exception('Internal Server Error');
  } else {
    throw Exception('Unknown Error');
  }
}
```
</details>

### address
APIの仕様に合わせたデータの定義と、レスポンスを解析する関数、結果を表示するウィジェットを定義しています。

<details><summary>実際のレスポンス例</summary>

```json
{
	"message": null,
	"results": [
		{
			"address1": "高知県",
			"address2": "南国市",
			"address3": "蛍が丘",
			"kana1": "ｺｳﾁｹﾝ",
			"kana2": "ﾅﾝｺｸｼ",
			"kana3": "ﾎﾀﾙｶﾞｵｶ",
			"prefcode": "39",
			"zipcode": "7830060"
		}
	],
	"status": 200
}
```
</details>

<details><summary>address_data.dart</summary>

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

class AddressData {
  final int _status;
  final String? _message;
  final List<ResultsFields>? _results;

  const AddressData({
    required int status,
    required String? message,
    required List<ResultsFields>? results,
  })
      : _results = results,
        _message = message,
        _status = status;

  factory AddressData.fromJson(Map<String, dynamic> json){
    return AddressData(
      status: json['status'],
      message: json['message'] as String?,
      results: json['results'] != null
          ? (json['results'] as List<dynamic>)
          .map((item) => ResultsFields.fromJson(item))
          .toList()
          : [],
    );
  }

  Widget formatResult() {
    if (_results!.isEmpty) { //存在しない郵便番号を指定した場合ここ（ステータスコード200のみ返る）
      return const Text('存在しない郵便番号です');
    }
    return Center(
      child: DataTable(
        border: TableBorder.all(),
        columns: const[
          DataColumn(
              label: Text('都道府県名')
          ),
          DataColumn(
              label: Text('市区町村名')
          ),
          DataColumn(
              label: Text('町域名')
          )
        ],
        rows: _results!.map((item) => DataRow(
            cells: <DataCell>[
              DataCell(Text(item.address1)),
              DataCell(Text(item.address2)),
              DataCell(Text(item.address3)),
            ]
        )).toList(),
      ),
    );
  }
}

class ResultsFields{
  final String zipcode;
  final String prefcode;
  final String address1;
  final String address2;
  final String address3;
  final String kana1;
  final String kana2;
  final String kana3;

  const ResultsFields({
    required this.zipcode,
    required this.prefcode,
    required this.address1,
    required this.address2,
    required this.address3,
    required this.kana1,
    required this.kana2,
    required this.kana3,
  });

  factory ResultsFields.fromJson(Map<String, dynamic> json){
    return ResultsFields(
      zipcode: json['zipcode'],
      prefcode: json['prefcode'],
      address1: json['address1'],
      address2: json['address2'],
      address3: json['address3'],
      kana1: json['kana1'],
      kana2: json['kana2'],
      kana3: json['kana3'],
    );
  }
}
```
</details>

# 実行
実行すると以下のようになります。
4枚目の画像は`1234567`などの郵便番号に存在しない値を入れると表示されます。

<img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468216/41e82842-42d1-787b-701c-b3ac897187c7.png">

<img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468216/809c99d3-157f-df54-cdfa-b54b30dd1231.png">

<img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468216/b75538ae-2649-0afc-de37-3bd7314f9b4c.png">

<img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468216/d2793e07-8b7a-a7b3-8379-7f7ded37cb70.png">


# 改善したい点

### UI部分
今回はどちらかといえばFlutterでHTTPリクエストを行う練習のために作成したため、UI部分はほとんど力を入れていません。テーマカラーを変えたこと、ウィジェットを中央揃えにした程度です。
まだ使っていない機能もかなりあるので、もう少し凝ったUIも作れるように勉強しておきたいと考えています。

### 機能追加
現状は検索と結果表示だけなので、何かしら他にも機能を追加したいです。
例えば検索履歴を別ページから閲覧出来るようにする機能など？

### 状態管理
状態管理ライブラリ（Riverpod）の使い方を学び、今回のアプリにも導入したいです。
試しにRiverpodを使用したバージョンを書いてみたところ、二回目以降に入力した結果が反映されなかったり（おそらく二回目以降のAPI実行がされていない）と苦労している最中なので、まだまだ使いこなすには遠そうですが……。

# 最後に
まだまだプログラミング自体の経験が浅いため新しい言語を学ぶと簡単なプログラムでも時間がかかることもありますが、色々試しながら理解していきたいところです。