サイトアイコン 【TechGrowUp】

FlutterでGoogle Places APIが403になる原因と対策|アプリ制限付きAPIキーはSDK経由が安全

はじめに

FlutterアプリでGoogle Places APIを使って、目的地検索や場所候補の補完を実装しようとしたとき、意外とハマりやすいのが APIキー制限 です。

特に、Google Cloud ConsoleでAPIキーに Androidアプリ制限iOSアプリ制限 を設定したうえで、FlutterのDartコードからPlaces APIをHTTPで直接呼び出すと、403REQUEST_DENIED が返ってくることがあります。

一見すると「FlutterでGoogle Places APIが使えないの?」と思ってしまいますが、原因はFlutterそのものではありません。結論から言うと、Places APIのWeb Serviceを直接叩く場合、Android/iOSアプリ制限付きAPIキーは基本的に使えません。

モバイルアプリでAndroid/iOSアプリ制限付きAPIキーを使いたい場合は、Androidなら Places SDK for Android、iOSなら Places SDK for iOS を使う必要があります。

この記事では、FlutterでGoogle Places APIを使うときに403が返る理由と、Flutterアプリで安全にPlaces検索を実装する方法を整理します。

また、筆者が開発している旅行準備アプリ 「旅じたく」 でも、目的地検索や場所候補の入力体験を改善するためにGoogle Places APIの活用を検討しています。

この記事は単なる一般論ではなく、旅じたくの目的地検索機能を改善するための技術検証 として、FlutterでGoogle Places APIをどう扱うべきかを整理したものです。

旅じたくについて
「旅じたく」は、旅行前の準備をひとつにまとめられる旅行準備アプリです。持ち物、予定、予約メモ、予算、行きたい場所などを旅行ごとに整理できます。
今回扱うGoogle Places APIは、今後「目的地入力」や「行きたい場所」の入力体験を改善するための技術検証として位置づけています。

旅じたくアプリ – App Store
NINJA SYSTEMの「旅じたく」をApp Storeでダウンロードしてください。スクリーンショット、評価とレビュー、ユーザのヒント、「旅じたく」に似たゲームを見ることなどができます。
旅じたく – 旅行準備・持ち物 – Google Play のアプリ
旅行前の持ち物・予定・予算・予約をまとめて整理

この記事でわかること

この記事では、以下の内容を解説します。


前提:Google Places APIには複数の使い方がある

Google Places APIと一口に言っても、実際にはいくつかの利用方法があります。

代表的には以下です。

利用方法主な対象呼び出し方
Places API / Places API (New) Web Serviceサーバー・Web ServiceHTTPSリクエスト
Places SDK for AndroidAndroidアプリAndroidネイティブSDK
Places SDK for iOSiOSアプリiOSネイティブSDK
Places Library, Maps JavaScript APIWebJavaScript

Flutterアプリの場合、ついDartのhttpパッケージなどで以下のように呼び出したくなります。

final response = await http.get(
  Uri.parse('https://places.googleapis.com/v1/places:searchText?...'),
);

しかし、この呼び方は Places APIのWeb Serviceを直接叩いている 形です。

つまり、Androidアプリ上で動いていたとしても、Google側から見ると「Android SDK経由の呼び出し」ではなく、ただのHTTPSリクエストです。

ここが今回の落とし穴です。


なぜ403になるのか

Google Cloud Consoleでは、APIキーに対して以下のようなアプリケーション制限を設定できます。

Androidアプリ制限の場合は、パッケージ名と署名証明書のSHA-1フィンガープリントを設定します。

iOSアプリ制限の場合は、Bundle IDを設定します。

一見すると、FlutterアプリでもAndroid/iOSアプリとして動いているので、この制限が使えそうに見えます。

しかし、Places APIのWeb ServiceをDartのHTTP通信で直接呼び出した場合、そのリクエストはネイティブSDK経由ではありません。

そのため、Android/iOSアプリ制限付きAPIキーを使っていると、Google側でそのキーを正しく検証できず、403REQUEST_DENIED になります。


NG例:FlutterからREST APIを直接呼び出す

例えば、以下のようなコードです。

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

class BadPlacesApiClient {
  BadPlacesApiClient(this.apiKey);

  final String apiKey;

  Future<void> searchText(String query) async {
    final uri = Uri.https(
      'places.googleapis.com',
      '/v1/places:searchText',
    );

    final response = await http.post(
      uri,
      headers: {
        'Content-Type': 'application/json',
        'X-Goog-Api-Key': apiKey,
        'X-Goog-FieldMask': 'places.id,places.displayName,places.formattedAddress',
      },
      body: jsonEncode({
        'textQuery': query,
        'languageCode': 'ja',
        'regionCode': 'JP',
      }),
    );

    if (response.statusCode == 403) {
      throw Exception('403 Forbidden: APIキー制限により拒否されている可能性があります');
    }

    print(response.body);
  }
}

このコード自体が必ず悪いわけではありません。

ただし、Android/iOSアプリ制限付きAPIキーを使う構成とは相性が悪い です。

REST APIを直接使うなら、基本的にはサーバー側から呼び出す構成にするのが安全です。


FlutterでGoogle Placesを使う選択肢

FlutterでPlaces検索を実装する場合、現実的な選択肢は大きく4つあります。

方式概要メリットデメリット
DartからREST直叩きFlutter内でHTTP通信実装が簡単アプリ制限キーが使えない。キー漏洩リスクが高い
Flutterプラグイン利用ネイティブSDKをラップしたプラグインを使う実装が比較的簡単。アプリ制限キーを使いやすい非公式プラグインの場合、保守状況に依存
MethodChannel自作Android/iOSのPlaces SDKを自前で呼ぶ制御しやすい。長期運用向きAndroid/iOS両方の実装が必要
Backend ProxyCloudflare WorkersやFirebase Functions等で中継APIキーをアプリに含めなくてよいサーバー実装・運用が必要

個人開発アプリでは、まず 既存のFlutterプラグインを検証 し、要件に合わなければ MethodChannel自作、サーバー側の機能が増えてきたら Backend Proxy を検討する流れが現実的です。


方法1:flutter_google_places_sdkを使う

Flutter向けにGoogle公式のPlaces SDKがあるわけではありませんが、ネイティブSDKをラップしたFlutterプラグインはいくつか存在します。

その一つが flutter_google_places_sdk です。

このプラグインは、Android/iOSそれぞれのネイティブライブラリを利用する方針のため、単純なHTTPリクエスト方式よりもアプリ制限付きAPIキーと相性が良いです。

pubspec.yaml

dependencies:
  flutter:
    sdk: flutter
  flutter_google_places_sdk: ^0.4.3

※バージョンは例です。実際に導入する際はpub.devで最新バージョンと変更履歴を確認してください。

検索結果用モデル

class PlaceSuggestion {
  const PlaceSuggestion({
    required this.placeId,
    required this.primaryText,
    required this.secondaryText,
  });

  final String placeId;
  final String primaryText;
  final String secondaryText;
}

Places検索サービス

import 'package:flutter_google_places_sdk/flutter_google_places_sdk.dart';

class PlacesSearchService {
  PlacesSearchService({required String apiKey})
      : _places = FlutterGooglePlacesSdk(apiKey);

  final FlutterGooglePlacesSdk _places;

  Future<List<PlaceSuggestion>> search(String input) async {
    if (input.trim().isEmpty) {
      return const [];
    }

    final result = await _places.findAutocompletePredictions(
      input,
      countries: const ['JP'],
    );

    return result.predictions.map((prediction) {
      return PlaceSuggestion(
        placeId: prediction.placeId,
        primaryText: prediction.primaryText,
        secondaryText: prediction.secondaryText,
      );
    }).toList();
  }
}

UI側の例

import 'package:flutter/material.dart';

class DestinationSearchField extends StatefulWidget {
  const DestinationSearchField({super.key, required this.service});

  final PlacesSearchService service;

  @override
  State<DestinationSearchField> createState() => _DestinationSearchFieldState();
}

class _DestinationSearchFieldState extends State<DestinationSearchField> {
  final _controller = TextEditingController();
  List<PlaceSuggestion> _suggestions = const [];
  bool _loading = false;

  Future<void> _onChanged(String value) async {
    setState(() => _loading = true);

    try {
      final suggestions = await widget.service.search(value);
      if (!mounted) return;
      setState(() => _suggestions = suggestions);
    } catch (e) {
      if (!mounted) return;
      setState(() => _suggestions = const []);
      debugPrint('Places search failed: $e');
    } finally {
      if (mounted) {
        setState(() => _loading = false);
      }
    }
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        TextField(
          controller: _controller,
          decoration: InputDecoration(
            labelText: '目的地',
            hintText: '例:札幌、京都駅、東京タワー',
            suffixIcon: _loading
                ? const Padding(
                    padding: EdgeInsets.all(12),
                    child: SizedBox(
                      width: 16,
                      height: 16,
                      child: CircularProgressIndicator(strokeWidth: 2),
                    ),
                  )
                : null,
          ),
          onChanged: _onChanged,
        ),
        const SizedBox(height: 8),
        ..._suggestions.map(
          (suggestion) => ListTile(
            title: Text(suggestion.primaryText),
            subtitle: Text(suggestion.secondaryText),
            onTap: () {
              _controller.text = suggestion.primaryText;
              setState(() => _suggestions = const []);

              // ここでplaceIdを保存しておくと、あとから詳細情報を取得しやすい
              debugPrint('Selected placeId: ${suggestion.placeId}');
            },
          ),
        ),
      ],
    );
  }
}

注意点

この方法は実装が簡単ですが、プラグインはGoogle公式ではないため、以下は確認しておくべきです。

個人開発のMVPや小規模アプリでは十分現実的ですが、長期的に細かい制御をしたい場合はMethodChannel自作も候補になります。


方法2:MethodChannelでネイティブSDKを呼ぶ

既存プラグインに依存したくない場合は、FlutterからMethodChannelでAndroid/iOSのPlaces SDKを呼び出す方法があります。

構成は以下のようになります。

Flutter
  ↓ MethodChannel
Android: Places SDK for Android
iOS: Places SDK for iOS

Google Places

Flutter側は共通のインターフェースを持ち、Android/iOS側でそれぞれネイティブSDKを実装します。


Flutter側:MethodChannelクライアント

import 'package:flutter/services.dart';

class NativePlacesService {
  static const _channel = MethodChannel('jp.ninja.tripmemo/places');

  Future<List<PlaceSuggestion>> findAutocompletePredictions(String query) async {
    if (query.trim().isEmpty) {
      return const [];
    }

    final result = await _channel.invokeMethod<List<dynamic>>(
      'findAutocompletePredictions',
      {
        'query': query,
        'country': 'JP',
      },
    );

    if (result == null) {
      return const [];
    }

    return result.map((item) {
      final map = Map<String, dynamic>.from(item as Map);
      return PlaceSuggestion(
        placeId: map['placeId'] as String? ?? '',
        primaryText: map['primaryText'] as String? ?? '',
        secondaryText: map['secondaryText'] as String? ?? '',
      );
    }).toList();
  }
}

Android側:Kotlin実装イメージ

android/app/build.gradle.kts にPlaces SDKを追加します。

dependencies {
    implementation("com.google.android.libraries.places:places:5.1.1")
}

※バージョンは例です。実際には最新のPlaces SDK for Androidのバージョンを確認してください。

MainActivity.kt の例です。

package jp.ninja.tripmemo

import android.os.Bundle
import com.google.android.libraries.places.api.Places
import com.google.android.libraries.places.api.model.AutocompletePrediction
import com.google.android.libraries.places.api.net.FindAutocompletePredictionsRequest
import com.google.android.libraries.places.api.net.PlacesClient
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel

class MainActivity : FlutterActivity() {
    private val channelName = "jp.ninja.tripmemo/places"
    private lateinit var placesClient: PlacesClient

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)

        val apiKey = BuildConfig.PLACES_API_KEY
        if (!Places.isInitialized()) {
            Places.initializeWithNewPlacesApiEnabled(applicationContext, apiKey)
        }
        placesClient = Places.createClient(this)
    }

    override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
        super.configureFlutterEngine(flutterEngine)

        MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
            .setMethodCallHandler { call, result ->
                when (call.method) {
                    "findAutocompletePredictions" -> {
                        val query = call.argument<String>("query").orEmpty()
                        val country = call.argument<String>("country") ?: "JP"
                        findAutocompletePredictions(query, country, result)
                    }
                    else -> result.notImplemented()
                }
            }
    }

    private fun findAutocompletePredictions(
        query: String,
        country: String,
        result: MethodChannel.Result
    ) {
        val request = FindAutocompletePredictionsRequest.builder()
            .setQuery(query)
            .setCountries(country)
            .build()

        placesClient.findAutocompletePredictions(request)
            .addOnSuccessListener { response ->
                val predictions = response.autocompletePredictions.map { prediction ->
                    mapPrediction(prediction)
                }
                result.success(predictions)
            }
            .addOnFailureListener { exception ->
                result.error(
                    "PLACES_ERROR",
                    exception.message,
                    null,
                )
            }
    }

    private fun mapPrediction(prediction: AutocompletePrediction): Map<String, String> {
        return mapOf(
            "placeId" to prediction.placeId,
            "primaryText" to prediction.getPrimaryText(null).toString(),
            "secondaryText" to prediction.getSecondaryText(null).toString(),
            "fullText" to prediction.getFullText(null).toString(),
        )
    }
}

※SDKバージョンによってメソッド名や戻り値の型が変わる可能性があります。実装時は利用しているPlaces SDKのリファレンスに合わせて調整してください。


Android側:APIキー管理

本番ではAPIキーをソースコードに直書きしないようにします。

例えば、local.properties に以下のように定義します。

PLACES_API_KEY=YOUR_API_KEY

Gradle側で BuildConfig.PLACES_API_KEY として参照できるようにします。

android {
    defaultConfig {
        buildConfigField(
            "String",
            "PLACES_API_KEY",
            "\"${project.findProperty("PLACES_API_KEY") ?: ""}\""
        )
    }
}

実際にはGoogle公式が案内しているSecrets Gradle Pluginの利用も検討できます。


iOS側:Swift実装イメージ

iOS側では、Places SDK for iOSを導入し、AppDelegateなどでAPIキーを設定します。

import UIKit
import Flutter
import GooglePlaces

@main
@objc class AppDelegate: FlutterAppDelegate {
  private let channelName = "jp.ninja.tripmemo/places"

  override func application(
    _ application: UIApplication,
    didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
  ) -> Bool {
    GMSPlacesClient.provideAPIKey("YOUR_API_KEY")

    let controller = window?.rootViewController as! FlutterViewController
    let channel = FlutterMethodChannel(
      name: channelName,
      binaryMessenger: controller.binaryMessenger
    )

    channel.setMethodCallHandler { [weak self] call, result in
      switch call.method {
      case "findAutocompletePredictions":
        guard
          let args = call.arguments as? [String: Any],
          let query = args["query"] as? String
        else {
          result(FlutterError(code: "INVALID_ARGUMENT", message: "query is required", details: nil))
          return
        }

        let country = args["country"] as? String ?? "JP"
        self?.findAutocompletePredictions(query: query, country: country, result: result)

      default:
        result(FlutterMethodNotImplemented)
      }
    }

    GeneratedPluginRegistrant.register(with: self)
    return super.application(application, didFinishLaunchingWithOptions: launchOptions)
  }

  private func findAutocompletePredictions(
    query: String,
    country: String,
    result: @escaping FlutterResult
  ) {
    let token = GMSAutocompleteSessionToken()
    let filter = GMSAutocompleteFilter()
    filter.countries = [country]

    let request = GMSAutocompleteRequest(query: query)
    request.sessionToken = token
    request.filter = filter

    GMSPlacesClient.shared().fetchAutocompleteSuggestions(from: request) { suggestions, error in
      if let error = error {
        result(FlutterError(code: "PLACES_ERROR", message: error.localizedDescription, details: nil))
        return
      }

      let values = suggestions?.compactMap { suggestion -> [String: String]? in
        guard let place = suggestion.placeSuggestion else {
          return nil
        }

        return [
          "placeId": place.placeID,
          "primaryText": place.attributedPrimaryText.string,
          "secondaryText": place.attributedSecondaryText?.string ?? "",
          "fullText": place.attributedFullText.string,
        ]
      } ?? []

      result(values)
    }
  }
}

※iOS SDKもバージョンによってAPIが変わる可能性があります。特にPlaces SDK for iOSとPlaces Swift SDKではAPIの書き方が異なるため、導入バージョンに合わせて調整してください。


方法3:Backend Proxy方式はどうか

もう一つの選択肢として、Flutterアプリから直接Google Placesを呼ばず、Cloudflare WorkersやFirebase Functionsなどの自前バックエンドを経由する方法もあります。

Flutter

Backend / Cloudflare Workers / Firebase Functions

Google Places API

この方式のメリットは、Google APIキーをアプリに含めなくてよいことです。

一方で、以下のような設計が必要になります。

個人開発アプリでは、最初からBackend Proxyを用意すると実装量が増えます。

ただし、将来的にAIによる旅程生成やサーバー側のランキング処理、ユーザーごとの検索履歴管理などを入れるなら、Backend Proxy方式も有力です。


旅じたくではどう使うか

ここからは、実際に筆者が開発している旅行準備アプリ 「旅じたく」 を例に、Google Places APIをどう活用できるかを考えてみます。

旅じたくは、旅行前の準備をまとめて整理できるアプリです。

旅行ごとに、以下のような情報を管理できます。

旅行準備では、情報がいろいろな場所に散らばりがちです。

例えば、宿泊予約はメール、行きたい場所はGoogle Maps、予算はメモ、持ち物は別のチェックリスト、といった形です。

旅じたくでは、こうした旅行前の情報を 旅行単位でまとめて管理する ことを目指しています。

その中で、Google Places APIが特に活きるのが 目的地検索行きたい場所の入力 です。


目的地入力でGoogle Places APIを使うメリット

旅じたくでは、旅行作成時に目的地を入力します。

ここでGoogle Places APIを使うと、以下のような体験改善ができます。

例えば、ユーザーが「京都」と入力したときに、以下のような候補を表示できます。

京都駅
京都府京都市下京区

清水寺
京都府京都市東山区

嵐山
京都府京都市右京区

ユーザーが候補を選択したら、アプリ内では表示名だけでなく placeId も保存しておくと便利です。

class TripDestination {
  const TripDestination({
    required this.name,
    required this.placeId,
    this.address,
    this.latitude,
    this.longitude,
  });

  final String name;
  final String placeId;
  final String? address;
  final double? latitude;
  final double? longitude;
}

最初のMVPでは、Autocompleteで選択した placeId と表示名だけ保存し、必要になったタイミングでPlace Detailsを取得する形でも十分です。


旅じたくでの実装イメージ

旅じたくで目的地検索を入れるなら、まずは以下のような流れがよさそうです。

旅行作成画面

目的地入力

Google Places Autocompleteで候補表示

ユーザーが候補を選択

旅行データにplaceIdと表示名を保存

必要に応じてPlace Detailsで住所・緯度経度を取得

地図・天気・行きたい場所機能へ連携

アプリ内のデータとしては、最初からすべての詳細情報を保存する必要はありません。

まずは以下のような最小構成で十分です。

class TripPlace {
  const TripPlace({
    required this.placeId,
    required this.name,
    this.address,
  });

  final String placeId;
  final String name;
  final String? address;
}

後から地図表示や天気取得を強化したくなったタイミングで、緯度経度などを追加していくのが扱いやすいです。

class TripPlaceDetail {
  const TripPlaceDetail({
    required this.placeId,
    required this.name,
    required this.latitude,
    required this.longitude,
    this.address,
  });

  final String placeId;
  final String name;
  final double latitude;
  final double longitude;
  final String? address;
}

こうしておくと、旅じたくの中で以下のような拡張がしやすくなります。

単なる「場所検索」ではなく、旅行準備アプリ全体の使いやすさにつながる機能になります。


実装時の注意点

Google Places APIは便利ですが、実装時にはいくつか注意点があります。

1. APIキーは分ける

Android、iOS、Web、サーバーで同じAPIキーを使い回さない方が安全です。

例えば以下のように分けます。

places-android-key
places-ios-key
places-server-key

それぞれに適切なアプリケーション制限とAPI制限を設定します。

2. API制限を必ず設定する

APIキーには、利用するAPIだけを許可するAPI制限を設定します。

Placesで使うキーなら、Places関連のAPIだけを許可します。

APIキーが漏れたときに、他のGoogle Cloud APIまで使われるリスクを下げるためです。

3. Autocompleteはデバウンスする

Autocompleteはユーザーが文字を入力するたびにリクエストが発生しやすい機能です。

そのため、入力のたびに即リクエストするのではなく、300〜500ms程度のデバウンスを入れるのがおすすめです。

Timer? _debounce;

void onSearchTextChanged(String value) {
  _debounce?.cancel();
  _debounce = Timer(const Duration(milliseconds: 400), () {
    _search(value);
  });
}

4. 空文字や短すぎる文字では検索しない

1文字入力されるたびに検索すると、不要なリクエストが増えます。

if (query.trim().length < 2) {
  return const [];
}

5. セッショントークンを意識する

Places Autocompleteでは、入力候補の取得からユーザーの選択、詳細取得までを1つのセッションとして扱うために、セッショントークンを使えます。

料金やリクエスト管理にも関わるため、AutocompleteとPlace Detailsを組み合わせる場合は、セッショントークンの扱いを確認しておくとよいです。

6. 取得するフィールドを絞る

Place Detailsを取得する場合は、必要なフィールドだけを指定します。

例えば、アプリで必要なのが名前・住所・緯度経度だけなら、それ以外の情報を無理に取得しないようにします。


個人開発アプリならどの方式がよいか

個人開発アプリでFlutterからGoogle Places APIを使うなら、個人的には以下の順番で検討するのがよいと思います。

まずはFlutterプラグインを検証する

最初からMethodChannelを自作すると、Android/iOSそれぞれの実装が必要になります。

そのため、まずは flutter_google_places_sdk のようなネイティブSDKラッパー系プラグインで、目的の機能が実現できるか確認するのが現実的です。

プラグインで足りなければMethodChannelを検討する

プラグインの対応状況やAPIの自由度に不満が出てきたら、MethodChannelで自前実装するのがよいです。

特に、以下のような場合は自作の価値があります。

サーバー側機能が増えたらBackend Proxyを検討する

AIによる旅程生成や、サーバー側でのスポット推薦、検索履歴の分析などを行う場合は、Backend Proxy方式も候補になります。

ただし、単純な目的地検索だけなら、最初からサーバーを挟む必要はないケースも多いと思います。


まとめ

FlutterでGoogle Places APIを使うとき、DartからREST APIを直接呼び出すだけなら実装は簡単です。

しかし、その場合はAndroid/iOSアプリ制限付きAPIキーとの相性が悪く、403になることがあります。

モバイルアプリで安全にPlacesを使いたい場合は、以下のどれかを選ぶのが現実的です。

個人開発アプリでは、まずFlutterプラグインで小さく検証し、必要に応じてMethodChannel化するのがバランスの良い選択だと思います。

「旅じたく」のような旅行準備アプリでは、目的地検索、行きたい場所、地図表示、天気連携など、Places APIを活用できる場面は多くあります。

ただし、Google Places APIは便利な反面、APIキー制限や課金設計を誤るとトラブルになりやすい部分でもあります。

FlutterでPlaces検索を実装する場合は、最初に どの呼び出し方式なら、どのAPIキー制限が使えるのか を整理しておくのがおすすめです。


旅じたくの紹介

最後に少しだけ、今回の実装検討の背景になっているアプリを紹介します。旅じたく は、旅行前の準備をまとめて整理できるアプリです。旅行ごとに、持ち物、予定、予約メモ、予算、行きたい場所、関連リンクなどを管理できます。

旅行の準備は、メモアプリ、カレンダー、地図、予約メールなどに情報が散らばりがちです。旅じたくでは、それらを旅行単位でまとめて管理できるようにすることを目指しています。

今後は、Google Places APIを活用した目的地検索や場所候補の補完によって、旅行作成時の入力体験も改善していく予定です。

旅行前の準備を少しでもラクにしたい方は、ぜひ使ってみてください。

旅じたくアプリ – App Store
NINJA SYSTEMの「旅じたく」をApp Storeでダウンロードしてください。スクリーンショット、評価とレビュー、ユーザのヒント、「旅じたく」に似たゲームを見ることなどができます。
旅じたく – 旅行準備・持ち物 – Google Play のアプリ
旅行前の持ち物・予定・予算・予約をまとめて整理

補足:おすすめの実装方針

筆者なら、旅行系アプリのMVPでは次の順番で進めます。

  1. flutter_google_places_sdk でAutocompleteの動作検証
  2. Android/iOSでアプリ制限付きAPIキーが正しく使えるか確認
  3. 目的地名とplaceIdだけ保存
  4. 必要になったらPlace Detailsで住所・緯度経度を取得
  5. プラグインの制約が出たらMethodChannelへ移行
  6. サーバー側処理が増えたらBackend Proxyを検討

最初から大きく作りすぎず、まずは目的地入力の体験改善に絞って導入するのが良いと思います。旅行アプリでは、目的地入力のしやすさがそのまま旅行作成のしやすさにつながります。小さな改善ですが、ユーザー体験への効果はかなり大きいはずです。

モバイルバージョンを終了