Flutterで作成したアプリにRiverpodを導入する

# はじめに
先日、Flutterの学習として郵便番号検索APIを使用したアプリを作成しました。

https://qiita.com/asuka_nakatsu/items/cdbacf25916377141b27

こちらのアプリにRiverpodを導入し、Riverpodのプロバイダを使用して状態管理が出来るようにしました。
今回はその時のことを備忘録として残そうと思います。
なお、Riverpodの基本的な使用方法・プロバイダとは何ぞやという点に関しては今回は割愛させていただきます。

# 事前準備
以下のページを参考に、Riverpodをプロジェクトに追加します。

https://docs-v2.riverpod.dev/docs/getting_started

# プロバイダを追加
今回は以下の二つをプロバイダで管理するようにしました。

* ユーザーが入力した郵便番号の値（検索履歴も兼ねる）
* APIリクエストの状態（ローディング、完了、エラー等）

検索履歴に関しては前回の記事の時点では実装していなかった機能です。
最大20件の検索履歴を閲覧できるようにしました。


## ユーザーが入力した郵便番号の値
`StateNotifierProvider`を使用して実装しました。
ユーザーが値を入力し、Submitボタンを押した所でプロバイダの値が更新されます。
`List<String>`と`Map<String, AddressData>`の値を持っており、最新20件分の入力履歴を保持します。
`AddressData`は、APIリクエストの結果をJSONからパースして保持するためのクラスです。

検索履歴のページでは、`List<String>`の各要素をキーとして`Map<String, AddressData>`に保存されている検索結果を呼び出すことで履歴を参照します。

<details><summary>addressSearchHistoryProvider</summary>

```dart
final addressSearchHistoryProvider =
    StateNotifierProvider<AddressSearchHistoryState, AddressSearchHistory>(
        (ref) => AddressSearchHistoryState());

class AddressSearchHistory {
  AddressSearchHistory();

  List<String> inputHistory = [];
  Map<String, AddressData> searchResultHistory = {};
}

class AddressSearchHistoryState extends StateNotifier<AddressSearchHistory> {
  AddressSearchHistoryState() : super(AddressSearchHistory());

  // 検索履歴末尾にinputZipCodeの値を追加
  void addInputHistory(String inputZipCode) {
    state.inputHistory = [...state.inputHistory, inputZipCode];
    if (state.inputHistory.length > 20) {
      // 20件を超えた場合、削除する履歴の配列を作成し、保存済の検索結果から対応するものを削除する
      final removeKeyList =
          state.inputHistory.sublist(0, state.inputHistory.length - 20);
      _removeSearchCache(removeKeyList);

      // リスト末尾から20を引いたところを始点にしたリスト（最新20件）を作成し、履歴を更新
      state.inputHistory =
          state.inputHistory.sublist(state.inputHistory.length - 20);
    }
  }

  void _removeSearchCache(List<String> removeKey) {
    // 検索履歴から削除された郵便番号に対応する検索結果も削除する
    removeKey.forEach((key) {
      state.searchResultHistory.remove(key);
    });
  }

  // 検索結果を保存
  void addSearchAddressData(String input, AddressData data) {
    state.searchResultHistory[input] = data;
  }
}

```
</details>


## リクエストの状態
`FutureProvider.family`を使用して実装しました。
このプロバイダは非同期操作が可能なプロバイダで、リクエスト完了・ローディング・エラー等の状態を簡単に参照することが出来ます。

`FutureProvider.family`は`FutureProvider`と似ていますが、異なるパラメータを与えられるという違いがあります。
パラメータとして入力された郵便番号の値を渡すことで、値別のリクエストの状態と検索結果を保持してくれます。

<details><summary>FutureProvider.family</summary>

```dart
// 検索結果を返すプロバイダ
final addressDataProvider =
    FutureProvider.autoDispose.family<AddressData, String>((ref, input) async {
  try {
    final response = await fetchResult(
        'https://zipcloud.ibsnet.co.jp/api/search?zipcode=$input');
    final data = AddressData.fromJson(jsonDecode(response.body));
    // 検索結果を履歴として保存
    ref
        .read(addressSearchHistoryProvider.notifier)
        .addSearchAddressData(input, data);
    return data;
  } catch (e) {
    throw Exception(e);
  }
});
```

</details>

ユーザーが値を入力して検索結果ページに遷移する際に、前述の入力履歴の配列から最も新しい値をこのプロバイダに渡し、リクエストの状態に応じてページを描画します。

また、プロバイダの状態は`.autoDispose`で自動的に破棄しているため、破棄する前に保存しておきます。


# 実行
通常の検索・結果表示画面はほとんど前回の記事に載せたものと外観の変化なしなのでスクリーンショットは割愛します。
長い住所の場合はみ出してしまうことに気付いていなかったため水平スクロールが出来るようにすることで解消しましたが、大きな変化はありません。

検索履歴はこのような外観です。

<img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468216/436fe4d2-d9ed-2f9b-093f-fda093598a05.png">
<img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468216/4670175c-f713-610c-c4eb-b76198dc11a6.png">

履歴上部が新しいもので、リストの各項目をタップすると検索結果が見られます。

<img width="250" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3468216/95c8c2c4-1c11-71b6-b6aa-aeb364b4b1c9.png">


# 最後に
検索履歴の表示機能は思っていたよりも試行錯誤しました。
初め実装した際は`.autoDispose`を使用せずに検索結果を保持し続けるというもので、流石にそれはまずいと思い色々試して今回のようなコードに修正したのですが、それはそれで`FutureProvider.family`である必要性が薄くなってしまいました。
`FutureProvider.family`はユーザーの入力値をパラメータにするものには向いていなさそうですね……。

適切なプロバイダを選んで使いこなすには時間がかかりそうですが、今回はひとまず基本的な使い方を理解出来ただけでもよしとして今後も学んでいければと思っています。


# ソース

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

```dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'address_data.dart';
import 'form.dart';

void main() => runApp(const ProviderScope(child: 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(
          actions: <Widget>[
            IconButton(
              onPressed: () {
                // 履歴表示画面へ移動
                Navigator.push(
                    context,
                    MaterialPageRoute(
                        builder: (context) => const HistoryPage()));
              },
              icon: const Icon(Icons.history),
            )
          ],
          backgroundColor: Theme.of(context).colorScheme.inversePrimary,
          title: Text(title),
        ),
        body: ZipCodeForm());
  }
}

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

  @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: const Center(
          child: AddressSearchResult(),
        ),
      ),
    );
  }
}

class AddressSearchResult extends ConsumerWidget {
  const AddressSearchResult({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 最新の入力値（Listの最後の値）を取得
    final input = ref.read(addressSearchHistoryProvider).inputHistory.last;
    // 取得した入力値からAPIリクエストの結果を取得
    final addressData = ref.watch(addressDataProvider(input));

    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('検索結果'),
      ),
      body: Container(
        padding: const EdgeInsets.all(20),
        child: Center(
          child: Column(
            children: [
              addressData.when(
                data: (data) => data.formatResult(),
                error: (err, stack) => Text('Error : $err'),
                loading: () => const CircularProgressIndicator(),
              ),
              ElevatedButton(
                onPressed: () {
                  Navigator.pop(context);
                },
                child: const Text('return home'),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

class HistoryPage extends ConsumerWidget {
  const HistoryPage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final history = ref.read(addressSearchHistoryProvider);

    return Scaffold(
        appBar: AppBar(
          backgroundColor: Theme.of(context).colorScheme.inversePrimary,
          title: const Text('検索履歴'),
          actions: <Widget>[
            IconButton(
              onPressed: () {
                Navigator.pop(context);
              },
              icon: const Icon(Icons.home),
            )
          ],
        ),
        body: ListView.builder(
            itemCount: history.inputHistory.length,
            itemBuilder: (context, index) {
              // 逆順に参照出来るようにインデックスを変換する
              final reversedIndex = history.inputHistory.length - 1 - index;
              final zipCode = history.inputHistory[reversedIndex];

              return ListTile(
                title: Text(zipCode),
                onTap: () {
                  Navigator.push(
                      context,
                      MaterialPageRoute(
                          builder: (context) => HistoryDetailPage(
                              zipCode: zipCode,
                              item: history.searchResultHistory[zipCode]!)));
                },
              );
            }));
  }
}

class HistoryDetailPage extends StatelessWidget {
  const HistoryDetailPage(
      {super.key, required this.zipCode, required this.item});

  final String zipCode;
  final AddressData item;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
        appBar: AppBar(
          backgroundColor: Theme.of(context).colorScheme.inversePrimary,
          title: Text(zipCode),
        ),
        body: Container(
            padding: const EdgeInsets.all(30),
            child: Center(
                child: Column(
              children: [
                item.formatResult(),
                ElevatedButton(
                  onPressed: () {
                    Navigator.pop(context);
                  },
                  child: const Text('return'),
                ),
              ],
            ))));
  }
}


```
</details>

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

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

class ZipCodeForm extends ConsumerWidget {
  ZipCodeForm({Key? key}) : super(key: key);

  final _formKey = GlobalKey<FormState>();
  final _controller = TextEditingController();

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    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()) {
                  // 入力履歴を追加
                  ref
                      .read(addressSearchHistoryProvider.notifier)
                      .addInputHistory(_controller.text);

                  // 画面遷移
                  Navigator.push(
                      context,
                      MaterialPageRoute(
                          builder: (context) => const AddressSearchResult()));
                }
              },
              child: const Text('Submit')),
        ],
      ),
    );
  }
}

```
</details>

<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>

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

```dart
import 'dart:convert';

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:testapp_postalcode/request.dart';

// 検索結果を返すプロバイダ
final addressDataProvider =
    FutureProvider.autoDispose.family<AddressData, String>((ref, input) async {
  try {
    final response = await fetchResult(
        'https://zipcloud.ibsnet.co.jp/api/search?zipcode=$input');
    final data = AddressData.fromJson(jsonDecode(response.body));
    // 検索結果を履歴として保存
    ref
        .read(addressSearchHistoryProvider.notifier)
        .addSearchAddressData(input, data);
    return data;
  } catch (e) {
    throw Exception(e);
  }
});

final addressSearchHistoryProvider =
    StateNotifierProvider<AddressSearchHistoryState, AddressSearchHistory>(
        (ref) => AddressSearchHistoryState());

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 Scrollbar(
        child: SingleChildScrollView(
      scrollDirection: Axis.horizontal,
      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'],
    );
  }
}

class AddressSearchHistory {
  AddressSearchHistory();

  List<String> inputHistory = [];
  Map<String, AddressData> searchResultHistory = {};
}

class AddressSearchHistoryState extends StateNotifier<AddressSearchHistory> {
  AddressSearchHistoryState() : super(AddressSearchHistory());

  // 検索履歴末尾にinputZipCodeの値を追加
  void addInputHistory(String inputZipCode) {
    state.inputHistory = [...state.inputHistory, inputZipCode];
    if (state.inputHistory.length > 20) {
      // 削除する履歴の配列を作成し、保存済の検索結果から対応するものを削除する
      final removeKeyList =
          state.inputHistory.sublist(0, state.inputHistory.length - 20);
      _removeSearchCache(removeKeyList);

      // リスト末尾から20を引いたところを始点にしたリスト（最新20件）を作成し、履歴を更新
      state.inputHistory =
          state.inputHistory.sublist(state.inputHistory.length - 20);
    }
  }

  void _removeSearchCache(List<String> removeKey) {
    // 検索履歴から削除された郵便番号に対応する検索結果も削除する
    removeKey.forEach((key) {
      state.searchResultHistory.remove(key);
    });
  }

  // 検索結果を保存
  void addSearchAddressData(String input, AddressData data) {
    state.searchResultHistory[input] = data;
  }
}

```
</details>


# 参考ページ

https://docs-v2.riverpod.dev/ja/docs/providers/state_notifier_provider

https://docs-v2.riverpod.dev/ja/docs/providers/future_provider

https://docs-v2.riverpod.dev/ja/docs/concepts/modifiers/family