開発者ガイド(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.
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 |
|
| Supabase | Supabase | 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 link | OAuth / 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 テスト
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
- Flutter インストール:
fvm推奨。ルート直下の.fvmrc(なければ stable)に合わせる。 - Install Flutter: we recommend
fvm; follow.fvmrc(fall back to stable if missing). - 依存解決:
cd mobileapp/prototype/flutter && flutter pub get - Install deps:
cd mobileapp/prototype/flutter && flutter pub get - 環境変数:
run時に--dart-defineで渡す(次セクション 参照)。 - Env vars: pass via
--dart-defineatruntime (see next section). - 起動: 下の "よく使う flutter コマンド" を参照。
- 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-define)Environment 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_URL | Supabase エンドポイント(Auth / Realtime SDK 用) | Supabase endpoint (for Auth / Realtime SDK) | https://afmyqhicretqrjwmvsaa.supabase.co | |
SUPABASE_ANON_KEY | anon JWT(クライアント公開可) | Anon JWT (safe to ship in client) | — | |
PARKY_API_BASE | BFF のベース URL。ビルド単位で切替 | BFF base URL (swap per build target) | https://dev-api.parky.co.jp | |
MAPBOX_ACCESS_TOKEN | Mapbox 公開トークン(ドメイン制限付き) | Mapbox public token (domain-restricted) | — | |
SENTRY_DSN | Sentry クラッシュ計測(未配線・将来) | Sentry crash reporting (planned, not wired yet) | — |
--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 */ ]
}
}
codeは スネークケース機械可読コード。画面分岐に使うcodeis a snake_case machine-readable code — branch on it, not on the messagerequest_idはサーバログと紐付く UUID。サポート連絡時に必ず添えるrequest_idis a UUID that joins the server log — always include it when escalating- 422 は Zod バリデーション失敗で
issues[]に詳細が入る - 422 means Zod validation failed; drill into
issues[]for details
認証フローAuth flow
- 起動時に
Supabase.initialize(url, anonKey)を呼ぶ(main.dart) - Call
Supabase.initialize(url, anonKey)on startup (main.dart) - ログイン:
supabase.auth.signInWithPassword()/signInWithOtp()/signInWithOAuth() - Sign in via
signInWithPassword(),signInWithOtp(), orsignInWithOAuth() Supabase.instance.client.auth.currentSession?.accessTokenを BFF 呼出の Bearer に載せる- Attach
currentSession?.accessTokento every BFF request as a Bearer token - セッションは SDK が自動更新。期限切れ近傍での refresh は内部で行われる
- Session refresh is automatic — the SDK handles token rotation near expiry
- ログアウト:
supabase.auth.signOut()。同時にローカルキャッシュ(flutter_secure_storage等)もクリア - Sign out with
supabase.auth.signOut(); also wipe any local cache (flutter_secure_storage, etc.)
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)
image_pickerで端末から画像取得 →XFileを入手- Pick an image with
image_picker→ get anXFile POST /v1/storage/upload-urlに{ file_name, file_size, mime_type, category, is_public }を渡して presigned URL を取得- POST
/v1/storage/upload-urlwith{ file_name, file_size, mime_type, category, is_public }and get back a presigned URL - 返ってきた
upload_urlに 直接 PUT。バイト列は Workers を経由しない - PUT the bytes directly to
upload_url— they skip Workers - サーバ側で
assetsテーブルへ行が INSERT 済なので、asset_idを親エンティティに紐付ける PATCH / POST を呼ぶ - A row was already inserted into
assets, so call the parent entity's PATCH / POST with thatasset_id - 表示は
https://cdn.parky.co.jp/<s3_key>をcached_network_imageに渡す(CDN は匿名 GET) - Display by passing
https://cdn.parky.co.jp/<s3_key>tocached_network_image(anonymous CDN)
プッシュ通知(FCM)Push notifications (FCM)
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.
firebase_core+firebase_messagingをpubspec.yamlに追加- Add
firebase_core+firebase_messagingtopubspec.yaml flutterfire configureで iOS / Android / Web の設定ファイルを生成- Run
flutterfire configureto generate iOS / Android / Web configs - 起動後に権限要求 → FCM トークン取得 →
PUT /v1/me/push-tokensで upsert - After startup, ask for permission → fetch FCM token → upsert via
PUT /v1/me/push-tokens - 受信:
FirebaseMessaging.onMessage(foreground) +onMessageOpenedApp(tap) +onBackgroundMessage(バックグラウンド) - Receive:
onMessage(foreground) +onMessageOpenedApp(tap) +onBackgroundMessage(bg) - 配信ペイロードの
data.typeとdata.notification_idで deep link する運用 - Deep link from
data.type+data.notification_idin the payload
地図 (Mapbox)Maps (Mapbox)
- トークンは
--dart-defineで注入。Dart 側ではMapboxOptions.setAccessToken(Env.mapboxToken)を main で 1 回だけ呼ぶ - Pass the token via
--dart-define; callMapboxOptions.setAccessToken(Env.mapboxToken)once inmain - 位置権限は
permission_handlerで事前に確認。拒否された場合は「GPS なし」モード(住所入力ベース)にフォールバック - Check location permission with
permission_handlerup-front. If denied, fall back to a no-GPS mode (address input) - 駐車場マーカーは
/v1/parking-lots/nearbyの結果を使う。BFF が PostGIS RPC で半径検索を返す - Load parking-lot markers from
/v1/parking-lots/nearby; the BFF wraps a PostGIS radius-search RPC
テストTesting
- ユニットテスト:
test/配下に配置。data/のリポジトリ層はhttp.Clientをモックしてレスポンスを差し替える - Unit: place under
test/. Repositories mockhttp.Clientand inject fake responses - Widget テスト:
flutter_testのtestWidgets。Supabase や ゴールデンファイル依存は避けて UI 単体を検証 - Widget: use
testWidgetsfromflutter_test. Keep tests free of Supabase / goldens — verify the UI in isolation - 統合テスト:
integration_testパッケージ。CI でヘッドレス実機(Firebase Test Lab / BrowserStack)を使う想定だが現状は未配線 - Integration: the
integration_testpackage is envisioned (Firebase Test Lab / BrowserStack) but not wired yet - カバレッジ:
flutter test --coverageでcoverage/lcov.infoを吐く。Codecov 配線は未 - Coverage:
flutter test --coveragewritescoverage/lcov.info. Codecov upload is not wired yet
Lint / フォーマットLint / formatting
analysis_options.yamlでpackage:flutter_lintsを有効化。supabase.from()等の禁止呼び出しは カスタム lint ルールで lint エラー化 する方針(未実装タスク)analysis_options.yamlenablespackage:flutter_lints. Banning calls likesupabase.from()with a custom lint rule is planned but not yet implemented- PR 前には
dart analyze && dart format lib test && flutter testを全部パスさせる - Before opening a PR run
dart analyze && dart format lib test && flutter test
ビルド・配布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.
Deep link / Universal linkDeep links / Universal links
- カスタムスキーム:
parky://parking/{id}— OAuth リダイレクトや Push タップで使用 - Custom scheme:
parky://parking/{id}— used by OAuth redirects and push taps - Universal link:
https://parky.co.jp/app/parking/{id}→ iOS はapple-app-site-association、Android はassetlinks.jsonで bind - Universal links:
https://parky.co.jp/app/parking/{id}— bind withapple-app-site-associationon iOS andassetlinks.jsonon Android - 受信:
app_linksのuriLinkStreamをgo_routerにブリッジ - Receive: bridge
app_links'suriLinkStreamintogo_router
よくあるハマりどころCommon pitfalls
- 401 が出る:
currentSessionが null(未ログイン)か、トークンが期限切れ。UI 側でonAuthStateChangeを購読して自動 refresh を確認 - Getting 401:
currentSessionis null (not signed in) or the token expired. Subscribe toonAuthStateChangeand confirm refresh works - 403 が出る: BFF が RLS / 自前スコープで弾いている。
request_idをコピーして Workers Observability で元ログを確認 - Getting 403: the BFF rejected it at RLS / user-scope. Copy
request_idand dig into Workers Observability - 422 が出る: Zod validation 失敗。
issues[]にパス付きで理由が入っているので UI バリデーションを合わせる - Getting 422: Zod validation failed —
issues[]carries path-qualified reasons. Align UI validators - 画像が出ない:
cdn.parky.co.jpの CORS ではなく、PUT 時のContent-Type不一致が犯人のことが多い。presign 時と PUT 時で完全一致させる - Image missing: usually a
Content-Typemismatch at PUT, not a CORS issue. Keep it identical between the presign call and the PUT - OAuth redirect が戻らない: カスタムスキームが AndroidManifest / Info.plist に未登録。
parky://を両方で受ける設定を忘れずに - OAuth redirect never returns: the custom scheme isn't registered in AndroidManifest / Info.plist. Make sure
parky://is declared on both - Web プレビューで地図が出ない: Mapbox のトークンに Web ドメイン許可が無い。開発中は
localhost:*を allow list に入れる - Map missing in Web preview: the Mapbox token lacks the web domain. Add
localhost:*to the allow list for dev
参考リンクReferences
- Swagger UI — インタラクティブに叩ける BFF 仕様(Dev 固定、英訳オーバーレイあり)Interactive BFF spec (Dev only, with EN overlay)
- Redoc — 読み物向けの BFF 仕様Reading-oriented BFF spec
- Architecture — 全体アーキテクチャ(mobile 視点)Overall architecture from the mobile angle
- API reference — クライアントが叩く API の整理Organized list of APIs the client calls
- Data model — モバイルが読む PG テーブルPG tables read by mobile
- Notifications — 通知全般(Push / アプリ内)Notifications end-to-end (push / in-app)
- Mobile UI mockup — 動作可能プロトタイプInteractive prototype