開発者ガイド(Flutter モバイル) Developer guide (Flutter mobile)

Parky モバイルアプリ(Flutter)にコミットする開発者が最初に読むページです。 ローカル起動から BFF 呼び出し、Auth / Realtime / Push / 地図 / テスト / CI までを網羅的に解説します。 個別 API の詳細は Swagger UI / Redoc を参照してください。

The page every developer touching the Parky mobile app (Flutter) should read first. It walks through local setup, BFF calls, Auth / Realtime / Push / maps / testing / CI end-to-end. For per-route details see Swagger UI / Redoc.

前提知識: Assumes you know: Flutter 3.x / Dart 3.8+ の基本文法、pubspec.yaml の依存管理、flutter run。未経験なら 公式チュートリアル を一周してから戻ってくるのが無難です。 Basic Flutter 3.x / Dart 3.8+, dependency management via pubspec.yaml, and running flutter run. If you have no Flutter background, do the official tutorial first.

技術スタック(確定版)Stack (what we actually use)

レイヤLayer 採用Choice 備考Notes
言語・フレームワークLanguage / framework Flutter stable、Dart ^3.8.0 environment: で固定。CI は flutter-version-file: pubspec.yaml で揃える Pinned in environment:. CI matches it via flutter-version-file: pubspec.yaml
ルーティングRouting go_router ^14.8 deep link / universal link と一元管理。screens は lib/screens/、ルート定義は lib/app.dart Centralizes deep / universal links. Screens live in lib/screens/, route tree in lib/app.dart
状態管理State management Flutter 標準(ValueNotifier / InheritedWidget / setState)。Riverpod や BLoC は未導入 Stock Flutter (ValueNotifier / InheritedWidget / setState). Riverpod / BLoC are not used yet 仕様書(アーキテクチャ)では Riverpod を想定しているが、プロトタイプでは未配線。規模拡大時に再検討 The spec envisions Riverpod, but the prototype hasn't adopted it yet. Revisit as code grows
API クライアントAPI client http ^1.4 自動生成 Dart クライアントは未配線。現状は data/ 配下に手書きリポジトリ層を置いて http で直接 BFF を叩いている No generated Dart client yet. Hand-rolled repositories under data/ call the BFF directly with http
SupabaseSupabase supabase_flutter ^2.10 Auth / Realtime のみ。from() / rpc() / storage使わない(BFF 経由が契約) Auth / Realtime only. Do not use from() / rpc() / storage — everything else goes through the BFF
地図Maps mapbox_maps_flutter ^2.8 トークンは --dart-define=MAPBOX_ACCESS_TOKEN=... で注入、公開トークンは URL 制限付き Token injected via --dart-define=MAPBOX_ACCESS_TOKEN=.... Public token is domain-restricted
画像Images cached_network_image ^3.4 + R2 の CDN (cdn.parky.co.jp) Workers の /v1/storage/upload-url で PUT 済み URL を受け取ってアップロード、表示は公開 URL を直接 GET Upload via presigned PUT URL from Workers /v1/storage/upload-url; display fetches the public URL directly
位置情報Geolocation geolocator ^13.0 + permission_handler ^11.3 iOS/Android ともに whenInUse を既定。always は要件が出てから Defaults to whenInUse on both iOS / Android. Move to always only when a feature requires it
カメラ・アルバムCamera / album image_picker ^1.1 駐車メモ写真・レビュー写真・プロフィールで利用 Used by parking memos, review photos, and profiles
OAuth / Deep linkOAuth / deep link sign_in_with_apple ^6.1 + app_links ^6.3 Google / Facebook は Supabase OAuth の redirect 方式。deep link は parky://https://parky.co.jp/app/... の 2 系 Google / Facebook go through Supabase OAuth redirect. Deep links cover parky:// and https://parky.co.jp/app/...
プッシュPush FCM(端末側未配線、サーバ側は完成) FCM (client side not wired, server side complete) firebase_messaging 追加 + lib/services/push.dart 作成 + PUT /v1/me/push-tokens でトークン upsert が残タスク TODO: add firebase_messaging, create lib/services/push.dart, and upsert the token via PUT /v1/me/push-tokens

ディレクトリ構成Directory layout

mobileapp/prototype/flutter/
├── pubspec.yaml              # 依存固定(バージョン範囲は最小に保つ)
├── lib/
│   ├── main.dart             # エントリ: Supabase.initialize → runApp(ParkyApp)
│   ├── app.dart              # MaterialApp + go_router のルートテーブル
│   ├── config/               # 環境変数の読み込み、エンドポイント定義
│   ├── theme/                # カラー・タイポグラフィ・InputDecoration の共通化
│   ├── models/               # API レスポンスを受ける data class
│   ├── data/                 # BFF を叩くリポジトリ層(http + json serialize)
│   ├── domain/               # 画面が消費する純粋ロジック(計算・変換)
│   ├── widgets/              # 再利用パーツ(カード、ボタン、フォーム)
│   ├── screens/              # 画面単位ディレクトリ (1 画面 = 1 ディレクトリ)
│   └── utils/                # 日付・URL・エラー整形等の共通ヘルパー
├── assets/                   # 同梱画像・フォント・JSON (pubspec の flutter.assets に列挙)
├── android/ ios/ web/        # プラットフォーム固有設定
└── test/                     # widget / unit テスト
命名規則: Naming: Dart のファイル名は snake_case.dart。画面は parking_detail_screen.dart のように _screen.dart サフィックス、リポジトリは parking_repository.dart_repository.dart。テストは対応ファイルと同名で _test.dart Dart file names are snake_case.dart. Screens end in _screen.dart (e.g. parking_detail_screen.dart), repositories in _repository.dart. Tests mirror the source file with _test.dart.

ローカル起動Local setup

  1. Flutter インストール: fvm 推奨。ルート直下の .fvmrc(なければ stable)に合わせる。
  2. Install Flutter: we recommend fvm; follow .fvmrc (fall back to stable if missing).
  3. 依存解決: cd mobileapp/prototype/flutter && flutter pub get
  4. Install deps: cd mobileapp/prototype/flutter && flutter pub get
  5. 環境変数: run 時に --dart-define で渡す(次セクション 参照)。
  6. Env vars: pass via --dart-define at run time (see next section).
  7. 起動: 下の "よく使う flutter コマンド" を参照。
  8. Run: see the cheat sheet below.

よく使う flutter コマンドFlutter cheat sheet

# iOS シミュレータで起動(環境変数は --dart-define で注入)
flutter run -d "iPhone 15" \
  --dart-define=SUPABASE_URL=https://afmyqhicretqrjwmvsaa.supabase.co \
  --dart-define=SUPABASE_ANON_KEY=eyJ... \
  --dart-define=PARKY_API_BASE=https://dev-api.parky.co.jp \
  --dart-define=MAPBOX_ACCESS_TOKEN=pk....

# Android エミュレータ
flutter run -d emulator-5554 --dart-define=...

# Web プレビュー(プロトタイプ確認用。本番は iOS/Android のみ)
flutter run -d chrome --dart-define=...

# パッケージ追加
flutter pub add firebase_messaging

# 世代更新
flutter pub upgrade --major-versions

# lint / フォーマット
dart analyze
dart format lib test

# テスト
flutter test
flutter test --coverage   # coverage/lcov.info が出る

環境変数(--dart-defineEnvironment variables (--dart-define)

Dart コードからは const String.fromEnvironment('KEY', defaultValue: '...') で読む。 lib/config/env.dart に集約すると扱いやすい。実行時に上書きできないのでビルド単位で固定される点に注意。

Read from Dart via const String.fromEnvironment('KEY', defaultValue: '...'). Collect them in lib/config/env.dart. Values bake into the build — you cannot change them at runtime.

Key 用途Purpose dev 既定Dev default
SUPABASE_URLSupabase エンドポイント(Auth / Realtime SDK 用)Supabase endpoint (for Auth / Realtime SDK)https://afmyqhicretqrjwmvsaa.supabase.co
SUPABASE_ANON_KEYanon JWT(クライアント公開可)Anon JWT (safe to ship in client)
PARKY_API_BASEBFF のベース URL。ビルド単位で切替BFF base URL (swap per build target)https://dev-api.parky.co.jp
MAPBOX_ACCESS_TOKENMapbox 公開トークン(ドメイン制限付き)Mapbox public token (domain-restricted)
SENTRY_DSNSentry クラッシュ計測(未配線・将来)Sentry crash reporting (planned, not wired yet)
秘密情報は入れない: Never ship secrets: service_role key や admin 用 API キーを --dart-define で渡してはいけない。端末にビルド済みとして配布されるので必ず漏れる。サーバ側(Workers)シークレットにとどめること。 Do not pass service_role keys or admin API keys via --dart-define. They ship inside the built app and will leak. Keep them as Workers secrets on the server.

BFF を叩く契約Calling the BFF

クライアントが叩ける唯一の API 層は Cloudflare Workers BFF(/v1/* です。 OpenAPI 3.1 仕様 (parky/api/openapi.json) が Single Source of Truth で、認証は Supabase 発行の JWT を Authorization: Bearer <token> ヘッダに載せます。

The Cloudflare Workers BFF (/v1/*) is the only API surface clients call. The OpenAPI 3.1 spec at parky/api/openapi.json is the source of truth; auth is a Supabase-issued JWT in Authorization: Bearer <token>.

最小リポジトリ層サンプルMinimal repository sample

// lib/data/parking_repository.dart
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:supabase_flutter/supabase_flutter.dart';
import '../config/env.dart';

class ParkyApiException implements Exception {
  ParkyApiException(this.status, this.code, this.message, {this.requestId});
  final int status;
  final String code;
  final String message;
  final String? requestId;
  @override
  String toString() => 'ParkyApiException($status $code): $message';
}

class ParkingRepository {
  ParkingRepository({http.Client? client}) : _client = client ?? http.Client();
  final http.Client _client;

  Uri _u(String path, [Map<String, dynamic>? query]) =>
      Uri.parse('${Env.apiBase}$path').replace(
        queryParameters: query?.map((k, v) => MapEntry(k, '$v')),
      );

  Map<String, String> _headers() {
    final token = Supabase.instance.client.auth.currentSession?.accessToken;
    return {
      'Content-Type': 'application/json',
      if (token != null) 'Authorization': 'Bearer $token',
    };
  }

  Future<Map<String, dynamic>> _get(String path, [Map<String, dynamic>? q]) async {
    final res = await _client.get(_u(path, q), headers: _headers());
    return _parse(res);
  }

  Map<String, dynamic> _parse(http.Response res) {
    final body = res.body.isEmpty ? {} : jsonDecode(res.body) as Map<String, dynamic>;
    if (res.statusCode >= 400) {
      final err = body['error'] as Map<String, dynamic>? ?? const {};
      throw ParkyApiException(
        res.statusCode,
        (err['code'] as String?) ?? 'unknown_error',
        (err['message'] as String?) ?? res.reasonPhrase ?? 'error',
        requestId: err['request_id'] as String?,
      );
    }
    return body;
  }

  Future<Map<String, dynamic>> listHubs({int min = 1}) =>
      _get('/v1/hubs/publishable', {'min': min});

  Future<Map<String, dynamic>> nearbyLots({
    required double lat,
    required double lng,
    int radiusM = 1000,
  }) =>
      _get('/v1/parking-lots/nearby', {'lat': lat, 'lng': lng, 'radius_m': radiusM});
}

エラー形式(Workers 統一)Unified error shape (Workers)

{
  "error": {
    "code": "unprocessable_entity",
    "message": "Validation failed",
    "request_id": "uuid-v4",
    "issues": [ /* Zod validation issues */ ]
  }
}

認証フローAuth flow

  1. 起動時に Supabase.initialize(url, anonKey) を呼ぶ(main.dart
  2. Call Supabase.initialize(url, anonKey) on startup (main.dart)
  3. ログイン: supabase.auth.signInWithPassword() / signInWithOtp() / signInWithOAuth()
  4. Sign in via signInWithPassword(), signInWithOtp(), or signInWithOAuth()
  5. Supabase.instance.client.auth.currentSession?.accessToken を BFF 呼出の Bearer に載せる
  6. Attach currentSession?.accessToken to every BFF request as a Bearer token
  7. セッションは SDK が自動更新。期限切れ近傍での refresh は内部で行われる
  8. Session refresh is automatic — the SDK handles token rotation near expiry
  9. ログアウト: supabase.auth.signOut()。同時にローカルキャッシュ(flutter_secure_storage 等)もクリア
  10. Sign out with supabase.auth.signOut(); also wipe any local cache (flutter_secure_storage, etc.)
禁止事項: Hard no: supabase.from('...') / supabase.rpc('...') / supabase.storage.from('...') を直接叩くのは禁止。必ず BFF (/v1/*) 経由で。 Do not call supabase.from('...') / rpc('...') / storage.from('...') directly — always go through the BFF (/v1/*).

Realtime 購読Realtime subscriptions

通知・駐車セッション状態のライブ更新は supabase-flutter の Realtime を使います(データプレーン例外)。 RLS で行スコープをかけるため、サブスクライブ対象のテーブルに対して自分の user_id フィルタを付けます。

Live updates for notifications and parking sessions use supabase-flutter's Realtime (a data-plane exception). RLS scopes the rows, so always filter subscriptions by your own user_id.

final channel = Supabase.instance.client
    .channel('public:user_notifications')
    .onPostgresChanges(
      event: PostgresChangeEvent.insert,
      schema: 'public',
      table: 'user_notifications',
      filter: PostgresChangeFilter(
        type: PostgresChangeFilterType.eq,
        column: 'user_id',
        value: Supabase.instance.client.auth.currentUser!.id,
      ),
      callback: (payload) {
        // show snackbar / badge update
      },
    )
    .subscribe();

// dispose 時
channel.unsubscribe();

画像アップロード(R2)Image upload (R2)

  1. image_picker で端末から画像取得 → XFile を入手
  2. Pick an image with image_picker → get an XFile
  3. POST /v1/storage/upload-url{ file_name, file_size, mime_type, category, is_public } を渡して presigned URL を取得
  4. POST /v1/storage/upload-url with { file_name, file_size, mime_type, category, is_public } and get back a presigned URL
  5. 返ってきた upload_url直接 PUT。バイト列は Workers を経由しない
  6. PUT the bytes directly to upload_url — they skip Workers
  7. サーバ側で assets テーブルへ行が INSERT 済なので、asset_id を親エンティティに紐付ける PATCH / POST を呼ぶ
  8. A row was already inserted into assets, so call the parent entity's PATCH / POST with that asset_id
  9. 表示は https://cdn.parky.co.jp/<s3_key>cached_network_image に渡す(CDN は匿名 GET)
  10. Display by passing https://cdn.parky.co.jp/<s3_key> to cached_network_image (anonymous CDN)

プッシュ通知(FCM)Push notifications (FCM)

現状: 未配線 Status: not wired yet サーバ側は parky-fcm-dispatch キュー + consumer まで完成していて、POST /v1/admin/user-notifications/{id}/send で即時 202 返却。残タスクは Flutter 側のトークン取得・登録・受信ハンドリングのみ。 The server side is complete (parky-fcm-dispatch queue + consumer; POST /v1/admin/user-notifications/{id}/send returns 202 immediately). What's left is Flutter-side token registration and receive handling.
  1. firebase_core + firebase_messagingpubspec.yaml に追加
  2. Add firebase_core + firebase_messaging to pubspec.yaml
  3. flutterfire configure で iOS / Android / Web の設定ファイルを生成
  4. Run flutterfire configure to generate iOS / Android / Web configs
  5. 起動後に権限要求 → FCM トークン取得 → PUT /v1/me/push-tokens で upsert
  6. After startup, ask for permission → fetch FCM token → upsert via PUT /v1/me/push-tokens
  7. 受信: FirebaseMessaging.onMessage (foreground) + onMessageOpenedApp (tap) + onBackgroundMessage (バックグラウンド)
  8. Receive: onMessage (foreground) + onMessageOpenedApp (tap) + onBackgroundMessage (bg)
  9. 配信ペイロードの data.typedata.notification_id で deep link する運用
  10. Deep link from data.type + data.notification_id in the payload

地図 (Mapbox)Maps (Mapbox)

テストTesting

Lint / フォーマットLint / formatting

ビルド・配布Build & release

ターゲットTarget コマンドCommand 成果物Output
Android (Play Store)Android (Play Store) flutter build appbundle --release --dart-define=... build/app/outputs/bundle/release/app-release.aab
iOS (App Store)iOS (App Store) flutter build ipa --release --dart-define=... build/ios/ipa/*.ipa
Android (社内配布)Android (internal) flutter build apk --release --dart-define=... build/app/outputs/flutter-apk/app-release.apk

Signing 用の keystore.jks(Android)や App Store Connect API Key(iOS)は 1Password の HF|開発 vault から取得し、CI 実行時のみ復号して使う。レポジトリにはコミットしない。

Signing materials — keystore.jks (Android) and the App Store Connect API Key (iOS) — live in 1Password (HF|開発 vault) and are decrypted only during CI. Never commit them.

よくあるハマりどころCommon pitfalls

参考リンクReferences