🍜

【個人開発】二郎系ラーメンのマップ検索&クチコミ投稿アプリを作った話

2022/02/22に公開

はじめに

はじめまして!現在大学院に通いながらFlutterでアプリ開発をしているわかなおです。

普段はFlutter大学というコミュニティに所属しながら開発を行なっています。
このコミュニティではFlutter製の素敵なアプリが多くリリースされています。コミュニティ内で楽しくFlutterを学べるので興味ある方はぜひ覗いてみてください!

今回は二郎系ラーメンのマップ検索&クチコミ投稿アプリを開発したので、アプリに関する技術的なことをまとめていきます。

どんなアプリ作ったん?

今回開発したアプリは「二郎系ラーメンのマップ検索&クチコミ投稿アプリ」です。

https://twitter.com/jiroseikatsu/status/1484798590463938563?s=21

なんで作ったん?

まずはじめに

  • マップとタイムラインを組み込んだアプリを作りたかった
  • ユーザー数を伸ばせる&競合のいないアプリを作りたかった

そこで良い題材がないか探していたところ

  • 中毒性の高いコンテンツ
  • AppStoreで競合となるアプリがない

この二つを満たす「二郎」というキーワードに目をつけ開発に至りました。(僕も大学1、2年生の時にはよく二郎に行ってましたが、最近は健康面を考えほとんど食べていないです。 食いてえ。。。)

主な使用技術

本アプリはFlutter+Firebaseで開発しました。
Firebaseでは以下のサービスを利用しました。

  • Authentication (メールアドレス、Apple、Googleログイン)
  • Cloud Firestore (データベース)
  • Storage(写真の保存)
  • Hosting(プライバシーポリシーなどの簡単なWebページのデプロイ)
  • Functions(プッシュ通知、Firestoreのデータの整合性を保つ)

状態管理の手法

主に使い慣れてる Riverpod + ChangeNotifierを使って開発を行いました。
所々勉強のために Riverpod + StateNotifier + Freezed でも書いたりしてみました。個人開発のいいところとしては使ってみたいなと思ったものをすぐに取り入れられるのが良いですよね。

Riverpodの公式ドキュメントは日本語にも対応したので、公式を参照するのが一番良いと思います!
https://riverpod.dev/ja/docs/getting_started/

実装

各画面についていくつか説明します。

マップ画面

マップ画面はGoogleMapとお店のカードで主に構成されています
マップ画面

  • 下部のお店のカードを横にシュッとやると連動してGoogleMapのマーカーがその店舗に移動する方法
  • マーカーをタップするとそこに移動し、カードもその店舗に移動する方法
    を紹介します!

お店のカード

下部のお店のカードはPageViewを用いて作っています。
https://api.flutter.dev/flutter/widgets/PageView-class.html

お店のカード
final pageController = PageController(
    viewportFraction: 0.85,//この値によって左右のカードがちょっと見える
);

PageView(
  controller: pageController,
  children: //省略,
)

カードをシュッて横にスワイプした時にカメラも一緒に動かす

shopクラス
//お店に関する情報を保持させている
class Shop {
  String? uid;
  String? name;
  double? latitude;
  double? longitude;
  
  //省略
}
カードをシュッてした時にカメラも一緒に動かす方法
PageView(
    //横にシュッとした時にカメラのポジションを移動させる
  onPageChanged: (int index) async{ //横にシュッとした時に呼ばれる
      final shop = shops.elementAt(index);//スワイプ後のshopを取得
      final zoomLevel = await mapController.getZoomLevel();//現在のズームレベルを取得(現在のズームの倍率を変えないため)
    //GoogleMapControllerのメソッドで任意の座標にカメラポジションを移動させる
    await mapController.animateCamera(
      CameraUpdate.newCameraPosition(
        CameraPosition(
          target: LatLng(shop.latitude!, shop.longitude!),
          zoom: zoomLevel,
        ),
      ),
    );
  },
  controller: pageController,
  children: //省略,
)

マーカーをタップした時にそこに移動しカードも移動させる

マップ画面

マーカーをタップした時
GoogleMap(
        markers: shops.map((selectedShop) {
	 final marker = BitmapDescriptor.defaultMarker;
       
        return Marker(
            markerId: MarkerId(selectedShop.uid!),
            position: LatLng(selectedShop.latitude!, selectedShop.longitude!),
            icon: marker,
            onTap: () {
		final zoomLevel = await mapController.getZoomLevel();//現在のズームレベルを取得(現在のズームの倍率を変えないため)
	    //GoogleMapControllerのメソッドで任意の座標にカメラポジションを移動させる
	               await mapController.animateCamera(
	           CameraUpdate.newCameraPosition(
	        	  CameraPosition(
	        	    target: LatLng(selectedShop.latitude!, selectedShop.longitude!),
	        	    zoom: zoomLevel,
	        	  ),
	                ),
	            );
		//タップしたマーカーのshopのindexを取得
              final index = shops.indexWhere((shop) => shop == selectedShop);
	      //pageControllerのメソッドでそのカードまで飛ぶ
              pageController.jumpToPage(index);
            });
      }).toSet(),
    );

このようにPageViewとGoogleMapControllerの組み合わせで実現することができました。

タイムライン画面

写真表示 更新 無限スクロール
写真 更新 無限スクロール

タイムライン画面については上記の3つの要素について紹介していきます!

写真表示

写真の表示にはphoto_viewというパッケージを使用しています。
https://pub.dev/packages/photo_view

photo_view
PhotoViewGallery.builder(
  scrollPhysics: const BouncingScrollPhysics(),
  pageController: pageController,
  builder: (BuildContext context, int index) {
      return PhotoViewGalleryPageOptions(
      imageProvider: NetworkImage(imageURLs[index]),
      heroAttributes: PhotoViewHeroAttributes(tag: tags[index]),
      maxScale: PhotoViewComputedScale.contained,
    );

遷移元にHero、heroAttributesを適当に設定してあげると元ある位置に戻るような画面遷移を実現できます。
元ある位置に戻る

Heroやphoto_viewについてはこちらの記事がとても参考になりました!

https://qiita.com/kitoko552/items/6548aef075e497342783

https://zenn.dev/mamushi/articles/release_nyanbuzz

更新

更新
更新はみなさんおなじみRefreshIndicatorを使用しています。
ただデフォルトのインジケーター(クルクルするやつ)がイケていなかったため代わりにCupertinoActivityIndicatorを使用しています。

https://www.youtube.com/watch?v=ORApMlzwMdM

Column(
      children: [
      //リフレッシュ時に表示
        if (isRefreshing)
          const SizedBox(
            height: 52,
            child: Center(
              child: CupertinoActivityIndicator(),
            ),
          ),
        Expanded(
          child: RefreshIndicator(
            edgeOffset: -500,//デフォルトのインジケーターを無理矢理隠してる
            onRefresh: onRefresh, //最新の投稿を取得
            child: ListView.builder(
             //タイムライン部分 省略
            ),
          ),
        ),
      ],
    );

無限スクロール

無限スクロール

無限スクロールはscrollControllerを用いた方法で実装しています。

scrollControllerをlisten
scrollController.addListener(
      () {
        //下までスクロールした場合に新しいpostを取得する
        if (scrollController.position.pixels ==
            scrollController.position.maxScrollExtent) {
          fetchMorePosts();
        }
      },
    );
page側
ListView.builder(
    cacheExtent: 1000,
    controller: scrollController,
    itemCount: posts.length + 1,
    itemBuilder: (context, index) {
      if (index == posts.length) {
         return lastTile();
      }
	//timelineTileは個々の投稿部分
      return timelineTile(
        context: context,
        post: posts[index],
       );
      },
    ),
lastTile
//全ての投稿を取得できていればSizedBox、まだ残っている投稿があればインジケーターを表示
 Widget lastTile() {
    if (isFetchAllPost) {
      return const SizedBox(
        height: 50,
      );
    } else {
      return const Padding(
        padding: EdgeInsets.all(30),
        child: CupertinoActivityIndicator(),
      );
    }
  }

タイムライン画面の開発は色々な要素があったのでとても勉強になりました!

検索画面

検索画面
検索画面はAlgoliaなどを使用せず、これらの記事を参考にFirestoreだけで全文検索を実装しました。
詳しいことを知りたい方は以下の記事を参照してみてください!とても参考になります。
https://qiita.com/oukayuka/items/d3cee72501a55e8be44a
https://qiita.com/KosukeSaigusa/items/6aaeac529475c03d7a2c

簡単に説明すると
「夢を語れ広島」を次のように2文字ずつ分解したMapをFirestoreに保存する

biGramMap = {
  '夢ヲ': true,
  'ヲ語': true,
  '語レ': true,
  'レ広': true,
  '広島': true,
};

「夢ヲ」を入力→一致したものを取得。という流れです。

Query query = FirebaseFirestore.instance
    .collection('shop')
    .where('夢ヲ', isEqualTo: true)
    .get();

Firestoreには実際に以下のように値が保存されています。
Firestoreでの保存内容

使用した便利だったライブラリまとめ

geoflutterfire
中心から半径◯m以内の店舗を取得するために使用

pedantic_mono
静的解析のために使用

permission_handler
位置情報や写真を使用するための許可を取るために使用

app_review
アプリ内レビューのために使用

などなど

まとめ

以上「二郎系ラーメンのマップ検索&クチコミ投稿アプリ」の簡単な紹介でした!
他にも

  • Google,Appleでのサインイン
  • Dart-defineを使って開発環境と本番環境を分ける(使い手にはわからないけど)
  • プッシュ通知

など色々頑張ってみたのでぜひ触っていただけると嬉しいです!

個人開発は、自分の作りたいものを形にできることや、取り入れたい技術をすぐに取り入れることができたりと、とても良いものだなと思っています!先人たちが残した記事を参考にしてこのアプリを作ることができたので、この記事が他の誰かの参考になってまた素敵なアプリが生まれたら嬉しいなと思っています。最後まで読んでいただきありがとうございました!

Flutter大学

Discussion