【0からのflutter/part2】Firebaseから取得した位置情報をリスト表示 + ボタンタップによるソート

# はじめに
この記事を読み終わると、以下のアプリができます。

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/270898/c22263e4-e485-8e60-cda3-19834e189c34.gif">

ちなみにこの記事は、以下の記事の続編です。

https://qiita.com/ninoko1995/items/b75abe29b57d94a67b87

上記の記事で実装したアプリに対し、以下の機能を追加していきます。

- google map上への現在地の表示機能
- google map上にHomeボタンの追加
- 取得した場所情報の一覧表示機能
- 一覧ページの複数要素でのソート機能

# 現在地の表示

以下の記事に従うと、現在地を表示することができるようになる。

https://qiita.com/kurararara/items/78709987460024b72b04

気をつけないといけないのは、
今の実装では`StoreMap`は`statelessWidget`となっているのを現在地の有無により表示を変化させるために`statefullWidget`にしなければいけないという点。

iOSで現在地を変更するには、以下の記事を参考にした。
記事の中では`debag`の中にあると書いてあったが、自分のバージョンだと`Features`の中にあった。

https://qiita.com/mattak/items/420039c674fe4392a647


# ホームボタンの追加

これは、StoreMapのStateの中で定義する。
理由は、MyHomeの中で定義してしまうと、地図画面と一覧画面を切り替えた際にも表示されたままになってしまうからだ。

手順

(1) GoogleMapのwidgetをscaffoldで作成するよう修正する
(2) ボタンタップ時に遷移するポジションを指定しておく
(3) floatingActionButtonをLocationをCenterにして設置する。
(4) タップ時の処理で、カメラを動かすよう記述

```dart:
class _StoreMapState extends State<StoreMap> {

  // 省略

  static final CameraPosition _mapHome = CameraPosition(
    target: LatLng(37.7763629, -122.4241918),
    zoom: 13.000,
  ); // <- (2)

  // 省略

  @override
  Widget build(BuildContext context) {
   return
     Scaffold(　// <- (1)
       body: GoogleMap(
         // 省略
       ),
       floatingActionButtonLocation: FloatingActionButtonLocation.centerFloat, // <- (3)
       floatingActionButton: FloatingActionButton.extended(
         onPressed: _goHome, // <- (4)
         label: Text('Home'),
         icon: Icon(Icons.home),
       ),
    　);
  }
}
```

今回は一旦、map上のmerkerからちょっとずらした場所をhomeに設定しておいた。

# 一覧表示
## 画面切り替えの実装

BottomNavigationBarを使い、画面切り替えを実現する。
このアプリでは、スポットの地図表記と一覧表記を実現したい。
そのページの遷移を実現するためのボタンを作ってみる。

https://dev.classmethod.jp/articles/basic_bottom_navigation_flutter/

上記ページを参考に、Map画面とCounter画面（デフォルトで作ってあるやつ）を遷移する機能をアプリに追加.
記事内では、ページ遷移の際にアニメーションを用いているが、本アプリではスライドアニメーションは不要とした。


この画面切り替えを実装すると、画面切り替え操作をしたときに以下のエラーが出た。

```dart:
Performing hot restart...
Syncing files to device iPhone 12...
Restarted application in 506ms.
[VERBOSE-2:ui_dart_state.cc(199)] Unhandled Exception: Bad state: Future already completed
#0      _AsyncCompleter.complete (dart:async/future_impl.dart:45:31)
#1      _StoreMapState.build.<anonymous closure> (package:map_test_2/widgets/map.dart:110:36)
#2      _GoogleMapState.onPlatformViewCreated (package:google_maps_flutter/src/google_map.dart:355:19)
<asynchronous suspension>
```

何がいけないのかコードを一つずつ検証していったところ、どうやら`mapController`をStoreMapに渡しているのが悪かったらしい。
`mapController`を渡さず、StoreMapの中で自分でmapControllerを定義するようにしたら解決した。

しかしこの解決方法だと、カルーセルと別のcontrollerを使うことになってしまうので、カルーセルタップ時に選択されたスポットを地図の中心に表示する機能が使えなくなってしまう。

この問題は、後で以下の記事を参考に修正する。

https://qiita.com/YusukeIwaki/items/28d81ec14b0e5ab9e148

今回やりたいのは、リスト表示の方の作り込みなので。

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/270898/5e1b96c8-f1d9-df98-c6a9-3e2503737ec9.gif">

## データの追加

indexページができても、登録データが一つだけだとリストの見た目が悪いので、
何個か他のデータを入れておく。

flutter公式のサンプルアプリのreadmeに記されているデータ形式は以下。

```
ice_cream_stores:
    placeId: ChIJ70taCKKAhYAR5IMmYwQT4Ts
    address: 432 Octavia St #1a, San Francisco, CA 94102, USA
    location: 37.7763629, -122.4241918
    name: Smitten Ice Cream
```

このplaceIdとは、場所に多対1で紐づいている値らしい。
placeIdがわかれば場所の特定ができるが、場所からplaceIdは一意には特定できない、ということ。

https://qiita.com/ryo0301/items/a5f7c350f21d95732509

以下のページで場所からplaceIdと住所を取得し、二つ目のページで住所から緯度経度を取得してfirebaseのcloud firestoreに保存する。

https://developers.google.com/maps/documentation/places/web-service/place-id

https://www.geocoding.jp/

とりあえず、適当な場所を5つくらい登録した。

![スクリーンショット 2021-09-13 22.02.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/270898/f8e563f2-62df-2fc2-b181-7effb96ba414.png)


## listページの作成

現在counter画面で仮置きしている部分を、スポット情報の一覧画面にする。
以下の記事を参考に、検索まで行えるようにしちゃう。

inputTextとListBuilderとCardの単体の使い方の例

https://dev.classmethod.jp/articles/flutter_api_call_1/

search用のinputTextFieldとlistを縦に並べるためにColumnを使う

https://qiita.com/shinbey221/items/a2af574622b8478b9cad

listを入れ子にするための解決策

https://blog.dalt.me/2303

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/270898/59f725a2-ce73-7fa8-245d-e00af4171ddb.gif">

## listページのソート機能
以下の記事群を参考に、設置するボタンのデザインを考える。

デザインの参考

https://qiita.com/coka__01/items/30716f42e4a909334c9f

https://qiita.com/piyonakajima/items/e0daab01da392bebb596

設置位置の参考

https://qiita.com/kaleidot725/items/35f6940e594bdf073eb8

ソート方式は、とりあえず「名前」と「緯度」。
緯度でソートしたいケースがわからないけど、とりあえず挙動の確認をするためにこれでいく。

<img width="300" src="https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/270898/fd4277f9-7839-d5ec-12d7-f2764a13b485.gif">


コードは以下。

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

enum SortType{
  name,
  latitude
}

class IndexPage extends StatefulWidget {
  const IndexPage({
    Key? key,
    required this.documents,
  }) : super(key: key);

  final List<DocumentSnapshot> documents;

  @override
  State<IndexPage> createState() => _IndexPageState();
}

class _IndexPageState extends State<IndexPage> {
  var _inputString;
  var _selectedSortType = SortType.name;

  void _tapSortButton(SortType sortType) {
    setState(() {
      _selectedSortType = sortType;
    });
  }

  // ボタン
  Widget _buildButtonArea() {
    return Container(
        child: Row(
            mainAxisAlignment: MainAxisAlignment.center,
            children: <Widget>[
              ElevatedButton(
                child: Text(
                  '名前',
                  style: TextStyle(
                    color: _selectedSortType == SortType.name ? Colors.black: Colors.white,
                  ),
                ),
                style: ElevatedButton.styleFrom(
                  primary: _selectedSortType == SortType.name ? Colors.white : Colors.grey,
                  onPrimary: Colors.white,
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(5),
                  ),
                ),
                onPressed: () {
                  _tapSortButton(SortType.name);
                },
              ),
              ElevatedButton(
                child: Text(
                  '緯度',
                  style: TextStyle(
                    color: _selectedSortType == SortType.latitude ? Colors.black: Colors.white,
                  ),
                ),
                style: ElevatedButton.styleFrom(
                  primary: _selectedSortType == SortType.latitude ? Colors.white: Colors.grey,
                  onPrimary: Colors.white,
                  shape: RoundedRectangleBorder(
                    borderRadius: BorderRadius.circular(5),
                  ),
                ),
                onPressed: () {
                  _tapSortButton(SortType.latitude);
                },
              ),
            ]
        )
    );
  }

  // 検索欄
  Widget _buildInput() {
    return Container(
        margin: const EdgeInsets.only(bottom: 20, left: 20, right: 20, top: 5),
        height: 40,
        child: TextField(
          decoration: InputDecoration(
            prefixIcon: Icon(Icons.search),
            hintText: '場所名前/住所を入力',
          ),
          onChanged: (inputString) {
            setState(() {
              _inputString = inputString;
            });
          },
        )
    );
  }

  // 場所の一覧
  Widget _buildPlacesList() {
    var _places = _inputString == null ? widget.documents : widget.documents.where((n) => n["name"].contains(_inputString) || n["address"].contains(_inputString)).toList();
    switch(_selectedSortType) {
      case SortType.name:
        _places.sort((a,b) => a["name"].compareTo(b["name"]));
        break;
      case SortType.latitude:
        _places.sort((a,b) => a["location"].latitude.compareTo(b["location"].latitude));
        break;
      default:
        break;
    }
    
    return ListView.builder(
      itemCount: _places.length,
      itemBuilder: (BuildContext context, int index) {
        final place = _places[index];
        return _buildCard(place);
      },
    );
  }

  // 場所のカード
  Widget _buildCard(DocumentSnapshot place) {
    return Card(
      margin: EdgeInsets.fromLTRB(16.0, 0.0, 16.0, 16.0),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: <Widget>[
          Padding(
            padding: EdgeInsets.all(12.0),
            child: Text(
              place["name"],
              style: TextStyle(
                  fontWeight: FontWeight.bold, fontSize: 16.0
              ),
            ),
          ),
          Padding(
            padding: EdgeInsets.fromLTRB(12.0, 0.0, 12.0, 12.0),
            child: Text(
                place["address"],
                style: TextStyle(
                    fontWeight: FontWeight.w200,
                    color: Colors.grey
                )
            ),
          ),
          Row(
            mainAxisAlignment: MainAxisAlignment.end,
            children: <Widget>[
              Icon(Icons.star),
              SizedBox(
                width: 50.0,
                child: Text("11"),
              ),
              Icon(Icons.remove_red_eye),
              SizedBox(
                width: 50.0,
                child: Text("12"),
              ),
            ],
          ),
          SizedBox(height: 16.0,)
        ],
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
        body: Column(
            children: <Widget>[
              _buildButtonArea(),
              _buildInput(),
              Expanded(
                  child: _buildPlacesList()
              ),
              // _buildBox(),
            ]
        )
    );
  }
}

```