diff --git a/app/.gitignore b/app/.gitignore
index 0228ebc..5d0cd3a 100644
--- a/app/.gitignore
+++ b/app/.gitignore
@@ -57,6 +57,8 @@ key.properties
**/google-services.json
# GoogleService-Info.plist 에서 생성한다 — tools/ios/inject_url_scheme.py
ios/config/Google.xcconfig
+# 구글 지도 API 키 — BUILD.md 「구글 지도 API 키」
+ios/config/Maps.xcconfig
# FVM Version Cache
.fvm/
diff --git a/app/BUILD.md b/app/BUILD.md
index ce1e8d6..5752762 100644
--- a/app/BUILD.md
+++ b/app/BUILD.md
@@ -145,6 +145,14 @@ Dart 는 실행·빌드 때 `--dart-define-from-file=config/kakao.env` 로 받
「설정 전」으로 막힌다. 서버 `fnAuthKakao` 는 카카오 **앱 ID**(숫자)를 `firebase/functions/.env.<프로젝트>`
의 `KAKAO_APP_ID` 로 받는다.
+**구글 지도 API 키(위치 선택 지도)** — 키는 저장소에 없다. 없어도 빌드는 되고 지도만 빈다.
+- Android: `android/local.properties` 에 `MAPS_API_KEY=<키>` → `build.gradle.kts` 가 `manifestPlaceholders` 로
+ AndroidManifest `com.google.android.geo.API_KEY` 에 넣는다.
+- iOS: `ios/config/Maps.xcconfig`(`ios/config/Maps.xcconfig.example` 복사, gitignore) 에 `MAPS_API_KEY = <키>` →
+ `Flutter/*.xcconfig` include → Info.plist `GMSApiKey` → `AppDelegate` 의 `GMSServices.provideAPIKey`.
+- 콘솔: Maps SDK for Android · iOS 사용 설정. 키 제한은 Android 앱 `kr.co.fanuzu.app` + SHA-1(디버그 · 릴리스),
+ iOS 번들 ID 로 건다.
+
Authentication 제공자는 이메일/비밀번호와 Google 둘 다 켜져 있다(2026-09-11). Google 을
켜야 `oauth_client` 와 `REVERSED_CLIENT_ID` 가 채워지며, 콘솔에서만 켤 수 있다.
diff --git a/app/android/app/build.gradle.kts b/app/android/app/build.gradle.kts
index 639f9e6..46c8676 100644
--- a/app/android/app/build.gradle.kts
+++ b/app/android/app/build.gradle.kts
@@ -25,6 +25,13 @@ val kakaoProperties = Properties().apply {
if (f.exists()) f.inputStream().use { load(it) }
}
+// 구글 지도 API 키 — `android/local.properties` 의 `MAPS_API_KEY=<키>`(gitignore). 없으면 빈 값으로
+// 빌드되고 지도만 비어 보인다(크래시 없음).
+val localProperties = Properties().apply {
+ val f = rootProject.file("local.properties")
+ if (f.exists()) f.inputStream().use { load(it) }
+}
+
/// 이번 실행이 릴리스 산출물(assembleRelease · bundleRelease 등)을 요구하는가.
val wantsRelease = gradle.startParameter.taskNames.any { it.contains("Release", ignoreCase = true) }
@@ -64,6 +71,7 @@ android {
// AndroidManifest 의 카카오 로그인 리다이렉트 스킴 `kakao{네이티브 앱 키}`.
manifestPlaceholders["kakaoNativeAppKey"] =
kakaoProperties.getProperty("FANUZU_KAKAO_NATIVE_APP_KEY", "")
+ manifestPlaceholders["mapsApiKey"] = localProperties.getProperty("MAPS_API_KEY", "")
}
signingConfigs {
diff --git a/app/android/app/src/main/AndroidManifest.xml b/app/android/app/src/main/AndroidManifest.xml
index a7c8133..ddcb241 100644
--- a/app/android/app/src/main/AndroidManifest.xml
+++ b/app/android/app/src/main/AndroidManifest.xml
@@ -83,6 +83,10 @@
+
+
+ GMSApiKey
+ $(MAPS_API_KEY)
+
LSApplicationQueriesSchemes
kakaokompassauth
diff --git a/app/ios/config/Maps.xcconfig.example b/app/ios/config/Maps.xcconfig.example
new file mode 100644
index 0000000..c208587
--- /dev/null
+++ b/app/ios/config/Maps.xcconfig.example
@@ -0,0 +1,2 @@
+// 구글 지도 API 키. 이 파일을 Maps.xcconfig 로 복사해 키를 넣는다(Maps.xcconfig 는 gitignore).
+MAPS_API_KEY = 여기에_키
diff --git a/app/lib/features/quest/place_picker_screen.dart b/app/lib/features/quest/place_picker_screen.dart
index 9db3e99..7c92bc4 100644
--- a/app/lib/features/quest/place_picker_screen.dart
+++ b/app/lib/features/quest/place_picker_screen.dart
@@ -1,10 +1,12 @@
-import 'dart:async';
import 'dart:ui' as ui;
+import 'package:flutter/foundation.dart' show Factory;
+import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
-import 'package:flutter_map/flutter_map.dart';
+import 'package:flutter/services.dart' show rootBundle;
import 'package:geocoding/geocoding.dart';
-import 'package:latlong2/latlong.dart';
+import 'package:geolocator/geolocator.dart' show Geolocator;
+import 'package:google_maps_flutter/google_maps_flutter.dart';
import '../../core/theme/tokens.dart';
import '../../core/i18n/app_locale.dart';
@@ -15,15 +17,22 @@ import 'quest_evidence.dart';
///
/// 명세 `523:18207` — 지도는 *디폴트 사용자 현재 위치*, 위치 마크는 *지도 영역 중간 정렬*, 지도를
/// 움직이면 가운데 좌표의 주소를 *실시간* 으로 보이고, *선택 완료* 는 위치 정보를 정상 호출한 뒤 켜진다.
-/// 운영이 등록한 장소는 지도 위 표시와
-/// 아래 목록으로 함께 보이고, 고르면 그 장소를 쓴다(해석 확인 요청 영역 B — 장소 등록 주체 권고안:
-/// 사용자 핀 → 장소 생성 · 운영 장소 우선 노출). 보상 인증 반경은 서버가 B-27(기본 100m)로 판정한다.
+/// 장소 검색 · 등록 장소 목록 · 등록 장소 핀은 Figma 와 설명 프레임 어디에도 없어 두지 않는다
+/// (해석 확인 요청 영역 B — Q-B-03 장소 등록 주체). 보상 인증 반경은 서버가 B-27(기본 100m)로 판정한다.
///
-/// 돌려주는 값: 등록 장소면 그 [QuestPlace], 핀이면 `id` 가 빈 [QuestPlace](좌표 · 주소).
+/// 돌려주는 값: 핀이 가리키는 `id` 가 빈 [QuestPlace](좌표 · 주소).
class PlacePickerScreen extends StatefulWidget {
- const PlacePickerScreen({super.key, this.places = defaultPlaces});
+ const PlacePickerScreen({
+ super.key,
+ this.mapBuilder = _googleMap,
+ this.lastKnown = _deviceLastKnown,
+ });
- final List places;
+ /// 지도 위젯 — 위젯 테스트는 플랫폼 뷰가 없어 가짜로 바꾼다.
+ final PickerMapBuilder mapBuilder;
+
+ /// 마지막으로 알려진 단말 위치(없으면 null) — 테스트는 플랫폼 채널이 없어 바꾼다.
+ final Future Function() lastKnown;
/// 어드민에 등록될 장소의 표본(좌표 없음). **정책값이 아니다.**
static const defaultPlaces = [
@@ -34,7 +43,7 @@ class PlacePickerScreen extends StatefulWidget {
QuestPlace(id: 'gocheok', name: '고척 스카이돔'),
];
- /// 등록 장소 좌표가 하나도 없을 때 지도를 처음 비추는 곳(서울시청). 화면 위치일 뿐 판정값이 아니다.
+ /// 현재 · 마지막 위치를 얻지 못했을 때 지도를 처음 비추는 곳(서울시청). 화면 위치일 뿐 판정값이 아니다.
static const fallbackCenter = LatLng(37.5665, 126.9780);
@override
@@ -42,69 +51,79 @@ class PlacePickerScreen extends StatefulWidget {
}
class _PlacePickerScreenState extends State {
- final _map = MapController();
- String _query = '';
+ GoogleMapController? _map;
+
+ /// 지도 가운데 좌표(마지막으로 안 값).
+ LatLng? _camera;
+ int _lookups = 0;
QuestPlace? _selected;
+ /// 지도를 처음 비추는 곳 — 마지막으로 알려진 위치를 읽기 전에는 지도를 만들지 않는다(null).
+ /// 현재 위치가 확정되기 전에 엉뚱한 곳이 비치지 않게 한다.
+ LatLng? _start;
+
/// 가운데 좌표의 주소를 찾는 중 — 이 동안은 *선택 완료* 를 잠근다(명세 「정상 호출 후 활성화」).
bool _resolving = false;
- Timer? _settle;
-
- List get _visible => widget.places
- .where((p) => _query.isEmpty || p.name.contains(_query))
- .toList();
-
- LatLng get _center {
- for (final p in widget.places) {
- if (p.lat != null && p.lng != null) return LatLng(p.lat!, p.lng!);
- }
- return PlacePickerScreen.fallbackCenter;
- }
@override
void initState() {
super.initState();
+ _startFromLastKnown();
_locate();
}
@override
void dispose() {
- _settle?.cancel();
+ _map?.dispose();
super.dispose();
}
- /// 디폴트 사용자 현재 위치. 권한이 없거나 꺼져 있으면 지금 화면(등록 장소 · 서울시청)에 둔다 —
+ /// 마지막으로 알려진 위치(있으면)에서 시작한다. 없거나 못 읽으면 [PlacePickerScreen.fallbackCenter].
+ Future _startFromLastKnown() async {
+ var start = PlacePickerScreen.fallbackCenter;
+ try {
+ start = await widget.lastKnown() ?? start;
+ } catch (_) {
+ // 플랫폼 구현 없음(테스트) · 미지원 — 기본 위치로 시작.
+ }
+ if (!mounted) return;
+ // 현재 위치가 먼저 와 카메라가 옮겨졌다면 그 자리에서 시작한다.
+ setState(() => _start = _camera ?? start);
+ }
+
+ /// 디폴트 사용자 현재 위치. 권한이 없거나 꺼져 있으면 지금 화면(마지막 위치 · 서울시청)에 둔다 —
/// 탐색은 권한 없이 열린다(B-27). 이때는 사용자가 지도를 움직여야 위치가 정해진다.
Future _locate() async {
try {
final fix = await devicePlaceFix();
if (!mounted || _selected != null) return;
final here = LatLng(fix.lat, fix.lng);
- _map.move(here, _map.camera.zoom);
+ _moveTo(here);
await _drop(here);
} catch (_) {
// 권한 거부 · 위치 꺼짐 · 플랫폼 구현 없음(테스트) — 기본 화면 유지.
}
}
- void _choose(QuestPlace place) {
- _settle?.cancel();
- setState(() {
- _selected = place;
- _resolving = false;
- });
- if (place.lat != null && place.lng != null) {
- _map.move(LatLng(place.lat!, place.lng!), _map.camera.zoom);
- }
+ void _moveTo(LatLng point) {
+ _camera = point;
+ _map?.moveCamera(CameraUpdate.newLatLng(point));
}
- /// 사용자가 지도를 움직이면 멈춘 뒤 가운데 좌표로 위치를 정한다(제스처가 아닌 이동 — 등록 장소
- /// 선택 · 현재 위치 이동 — 은 무시).
- void _moved(MapCamera camera, bool hasGesture) {
- if (!hasGesture) return;
- _settle?.cancel();
- setState(() => _resolving = true);
- _settle = Timer(const Duration(milliseconds: 400), () => _drop(camera.center));
+ /// 사용자가 지도를 움직이기 시작하면 주소 확인 전까지 *선택 완료* 를 잠근다.
+ void _moveStarted() => setState(() => _resolving = true);
+
+ /// 지도가 멈추면 가운데 좌표로 위치를 정한다. 코드가 옮긴 멈춤(현재 위치)은 이미 그
+ /// 좌표가 선택돼 있으므로 다시 찾지 않는다.
+ void _idle() {
+ final c = _camera;
+ if (c == null) return;
+ final s = _selected;
+ if (s != null && s.lat == c.latitude && s.lng == c.longitude) {
+ if (_resolving && _lookups == 0) setState(() => _resolving = false);
+ return;
+ }
+ _drop(c);
}
/// 가운데 좌표의 주소 — 단말 지오코더로 찾고, 못 찾으면 좌표를 그대로 보인다.
@@ -121,6 +140,7 @@ class _PlacePickerScreenState extends State {
);
});
var address = coords;
+ _lookups++;
try {
final marks = await Geocoding().placemarkFromCoordinates(
point.latitude,
@@ -133,6 +153,7 @@ class _PlacePickerScreenState extends State {
} catch (_) {
// 지오코더가 없거나(에뮬레이터 · 테스트 — 플랫폼 구현 없음) 실패 — 좌표 표시로 둔다.
}
+ _lookups--;
if (!mounted || _selected?.lat != point.latitude) return;
setState(() {
_resolving = false;
@@ -182,38 +203,20 @@ class _PlacePickerScreenState extends State {
],
),
),
- SizedBox(
- height: 300,
- child: FlutterMap(
- mapController: _map,
- options: MapOptions(
- initialCenter: _center,
- initialZoom: 15,
- onPositionChanged: _moved,
- ),
+ // 시안 `371:356705` — 지도가 남은 높이를 채우고 「선택 완료」가 그 위에 얹힌다.
+ Expanded(
+ child: Stack(
children: [
- TileLayer(
- urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
- userAgentPackageName: 'kr.co.fanuzu.app',
- // Figma `371:356705` 은 어두운 지도다 — 같은 OSM 타일을 어둡게 칠한다(flutter_map 내장).
- tileBuilder: darkModeTileBuilder,
- ),
- MarkerLayer(
- markers: [
- for (final p in widget.places)
- if (p.lat != null && p.lng != null)
- Marker(
- point: LatLng(p.lat!, p.lng!),
- child: GestureDetector(
- onTap: () => _choose(p),
- child: const Icon(
- Icons.place,
- color: AppColors.cosmicAccent,
- ),
- ),
- ),
- ],
- ),
+ if (_start != null)
+ widget.mapBuilder(
+ PickerMapArgs(
+ initial: _start!,
+ onCreated: (c) => _map = c,
+ onMoveStarted: _moveStarted,
+ onMove: (p) => _camera = p,
+ onIdle: _idle,
+ ),
+ ),
// 위치 마크 — *지도 영역 중간 정렬*. 핀 끝이 지도 가운데(선택 좌표)를 가리킨다.
const IgnorePointer(
child: Align(
@@ -227,85 +230,91 @@ class _PlacePickerScreenState extends State {
),
),
),
- // OSM 타일 이용 조건 — 출처 표기.
- const Align(
- alignment: Alignment.bottomRight,
- child: Text(
- '© OpenStreetMap',
- style: TextStyle(fontSize: 10, color: Colors.white54),
+ Align(
+ alignment: Alignment.bottomCenter,
+ child: SafeArea(
+ top: false,
+ child: Padding(
+ padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
+ child: FilledButton(
+ onPressed: pinned == null || _resolving
+ ? null
+ : () => Navigator.of(context).pop(pinned),
+ style: FilledButton.styleFrom(
+ minimumSize: const Size.fromHeight(56),
+ shape: RoundedRectangleBorder(
+ borderRadius: BorderRadius.circular(AppRadius.sm),
+ ),
+ backgroundColor: AppColors.cosmicAccent,
+ foregroundColor: Colors.black,
+ textStyle: const TextStyle(
+ fontFamily: AppFonts.body,
+ fontSize: 16,
+ fontWeight: FontWeight.w600,
+ height: 24 / 16,
+ ),
+ ),
+ child: Text(context.l10n.commonSelectDone),
+ ),
+ ),
),
),
],
),
),
- Padding(
- padding: const EdgeInsets.symmetric(horizontal: AppSpace.s4),
- child: TextField(
- decoration: InputDecoration(
- labelText: context.l10n.placeSearch,
- prefixIcon: Icon(Icons.search),
- ),
- onChanged: (v) => setState(() => _query = v.trim()),
- ),
- ),
- Expanded(
- child: _visible.isEmpty
- ? Center(
- child: Text(
- context.l10n.commonNoResults,
- style: Theme.of(context).textTheme.bodyMedium,
- ),
- )
- // 디폴트 미선택 · 단수 선택.
- : RadioGroup(
- groupValue: pinned?.id,
- onChanged: (v) =>
- _choose(widget.places.firstWhere((p) => p.id == v)),
- child: ListView.builder(
- itemCount: _visible.length,
- itemBuilder: (context, i) {
- final place = _visible[i];
- return RadioListTile(
- value: place.id,
- title: Text(place.name),
- secondary: const Icon(Icons.place_outlined),
- );
- },
- ),
- ),
- ),
- SafeArea(
- top: false,
- child: Padding(
- padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
- child: FilledButton(
- onPressed: pinned == null || _resolving
- ? null
- : () => Navigator.of(context).pop(pinned),
- style: FilledButton.styleFrom(
- minimumSize: const Size.fromHeight(56),
- shape: RoundedRectangleBorder(
- borderRadius: BorderRadius.circular(AppRadius.sm),
- ),
- backgroundColor: AppColors.cosmicAccent,
- foregroundColor: Colors.black,
- textStyle: const TextStyle(
- fontFamily: AppFonts.body,
- fontSize: 16,
- fontWeight: FontWeight.w600,
- height: 24 / 16,
- ),
- ),
- child: Text(context.l10n.commonSelectDone),
- ),
- ),
- ),
],
),
);
}
}
+/// 지도 위젯이 받는 값 — [PlacePickerScreen.mapBuilder].
+class PickerMapArgs {
+ const PickerMapArgs({
+ required this.initial,
+ required this.onCreated,
+ required this.onMoveStarted,
+ required this.onMove,
+ required this.onIdle,
+ });
+
+ final LatLng initial;
+ final ValueChanged onCreated;
+ final VoidCallback onMoveStarted;
+ final ValueChanged onMove;
+ final VoidCallback onIdle;
+}
+
+typedef PickerMapBuilder = Widget Function(PickerMapArgs args);
+
+Future _deviceLastKnown() async {
+ final p = await Geolocator.getLastKnownPosition();
+ return p == null ? null : LatLng(p.latitude, p.longitude);
+}
+
+/// Figma `371:356705` 어두운 지도 스타일(JSON 에셋). 한 번 읽어 둔다.
+final Future _mapStyle = rootBundle.loadString('assets/map/dark_style.json');
+
+Widget _googleMap(PickerMapArgs a) => FutureBuilder(
+ future: _mapStyle,
+ builder: (context, style) => GoogleMap(
+ initialCameraPosition: CameraPosition(target: a.initial, zoom: 15),
+ style: style.data,
+ onMapCreated: a.onCreated,
+ onCameraMoveStarted: a.onMoveStarted,
+ onCameraMove: (c) => a.onMove(c.target),
+ onCameraIdle: a.onIdle,
+ // 핀은 화면 가운데에 고정이라 기본 버튼은 쓰지 않는다.
+ zoomControlsEnabled: false,
+ myLocationButtonEnabled: false,
+ mapToolbarEnabled: false,
+ // 아래 목록 스크롤과 겹치지 않게 지도 안의 제스처는 지도가 가진다.
+ gestureRecognizers: {
+ Factory(EagerGestureRecognizer.new),
+ },
+ ),
+);
+
/// 지오코더 결과 → 한 줄 주소. 한국 주소는 도로명이 `thoroughfare` 에 오지 않아(번지만 `subThoroughfare`)
/// 칸을 이어 붙이면 「서울특별시 마포구 34」가 된다(10/6 실기기). 도로명이 든 `street` 를 쓴다 —
/// Android 는 주소 전체 줄(나라 이름 포함), iOS 는 「도로명 번지」만 준다.
diff --git a/app/lib/features/quest/quest_create_screen.dart b/app/lib/features/quest/quest_create_screen.dart
index 105ad1f..150872e 100644
--- a/app/lib/features/quest/quest_create_screen.dart
+++ b/app/lib/features/quest/quest_create_screen.dart
@@ -46,7 +46,6 @@ class _QuestCreateScreenState extends State {
QuestCatalog? _catalog;
QuestCategory? _category;
QuestActivity? _activity;
- List _places = const [];
QuestPlace? _place;
DateTime? _endDate;
QuestLink? _link;
@@ -442,14 +441,9 @@ class _QuestCreateScreenState extends State {
final _extraVerify = {};
Future _pickPlace() async {
- if (_places.isEmpty) {
- final places = await widget.repository.places();
- if (!mounted) return;
- _places = places;
- }
final picked = await Navigator.of(context, rootNavigator: true).push(
// 명세 `523:18207` — 선택 완료 시 *하단 move out 페이지 종료 후 이전 화면 이동*.
- SheetRoute(builder: (_) => PlacePickerScreen(places: _places)),
+ SheetRoute(builder: (_) => const PlacePickerScreen()),
);
if (picked == null || !mounted) return;
setState(() => _place = picked);
diff --git a/app/pubspec.lock b/app/pubspec.lock
index 9de4a10..5ee4246 100644
--- a/app/pubspec.lock
+++ b/app/pubspec.lock
@@ -201,14 +201,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.7"
- dart_earcut:
+ csslib:
dependency: transitive
description:
- name: dart_earcut
- sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b
+ name: csslib
+ sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
url: "https://pub.dev"
source: hosted
- version: "1.2.0"
+ version: "1.0.2"
dart_jsonwebtoken:
dependency: "direct overridden"
description:
@@ -217,14 +217,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.17.0"
- dart_polylabel2:
- dependency: transitive
- description:
- name: dart_polylabel2
- sha256: "7eeab15ce72894e4bdba6a8765712231fc81be0bd95247de4ad9966abc57adc6"
- url: "https://pub.dev"
- source: hosted
- version: "1.0.0"
device_info_plus:
dependency: transitive
description:
@@ -496,14 +488,6 @@ packages:
description: flutter
source: sdk
version: "0.0.0"
- flutter_map:
- dependency: "direct main"
- description:
- name: flutter_map
- sha256: "9bbe5472f66ae648d2ca2efbce97fcaa04e71261fabd3e062f26728222d7f767"
- url: "https://pub.dev"
- source: hosted
- version: "8.3.2"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@@ -631,6 +615,54 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.3.3+1"
+ google_maps:
+ dependency: transitive
+ description:
+ name: google_maps
+ sha256: "6811afff50dc410b860622ac89df125f5be1eada3ad69197e4c24dd18f2ef0e0"
+ url: "https://pub.dev"
+ source: hosted
+ version: "8.3.0"
+ google_maps_flutter:
+ dependency: "direct main"
+ description:
+ name: google_maps_flutter
+ sha256: "92b73931215d03057bb76c2207373851081c41bd1562f909a8bb14a98f9d7baa"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.18.2"
+ google_maps_flutter_android:
+ dependency: transitive
+ description:
+ name: google_maps_flutter_android
+ sha256: "9247f703b646e85c72213007853908ad473eb85dac73747f5dc9d370b8250b5c"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.21.0"
+ google_maps_flutter_ios:
+ dependency: transitive
+ description:
+ name: google_maps_flutter_ios
+ sha256: "9638557fa31d451d64c815d415b685f9959e314606bfa5daf2a77bc63f1eba04"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.18.6"
+ google_maps_flutter_platform_interface:
+ dependency: transitive
+ description:
+ name: google_maps_flutter_platform_interface
+ sha256: "742e86787abac8b08352aaf31b2d1fffc77038ab2e9ebd8d4d7013eec9e97218"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.17.0"
+ google_maps_flutter_web:
+ dependency: transitive
+ description:
+ name: google_maps_flutter_web
+ sha256: e9a78255ea50db294f9a6949d8b144313ed5555497dcab21b198022aaaa56641
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.6.4+1"
google_mobile_ads:
dependency: "direct main"
description:
@@ -695,6 +727,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.2.0"
+ html:
+ dependency: transitive
+ description:
+ name: html
+ sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699"
+ url: "https://pub.dev"
+ source: hosted
+ version: "0.15.7"
http:
dependency: transitive
description:
@@ -892,14 +932,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.1"
- latlong2:
- dependency: "direct main"
- description:
- name: latlong2
- sha256: "84b776b100a84a684a0aaa3fa4dc2c9b1931c658de533701ee4e09bfa991f8dc"
- url: "https://pub.dev"
- source: hosted
- version: "0.10.1"
leak_tracker:
dependency: transitive
description:
@@ -972,14 +1004,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.19.0"
- mgrs_dart:
- dependency: transitive
- description:
- name: mgrs_dart
- sha256: "385e7168ecc77eb545220223c49eef8ab249da7bf57f22781c40a04d23fb196f"
- url: "https://pub.dev"
- source: hosted
- version: "3.0.0"
mime:
dependency: transitive
description:
@@ -1212,14 +1236,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "5.0.6"
- proj4dart:
- dependency: transitive
- description:
- name: proj4dart
- sha256: ddcedc1f7876e62717de43ab3491e2829bdad0b028261805f94aa080967e5859
- url: "https://pub.dev"
- source: hosted
- version: "3.0.0"
pub_semver:
dependency: transitive
description:
@@ -1268,6 +1284,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.28.0"
+ sanitize_html:
+ dependency: transitive
+ description:
+ name: sanitize_html
+ sha256: "60257e2b8fd8b13b7c755065c2639a6c800031bb5bd3e30a007fc6739c77b2e1"
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.2.0"
sensors_plus:
dependency: "direct main"
description:
@@ -1380,14 +1404,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "3.0.0"
- simple_sparse_list:
- dependency: transitive
- description:
- name: simple_sparse_list
- sha256: aa648fd240fa39b49dcd11c19c266990006006de6699a412de485695910fbc1f
- url: "https://pub.dev"
- source: hosted
- version: "0.1.4"
sky_engine:
dependency: transitive
description: flutter
@@ -1417,6 +1433,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.4"
+ stream_transform:
+ dependency: transitive
+ description:
+ name: stream_transform
+ sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a
+ url: "https://pub.dev"
+ source: hosted
+ version: "2.1.2"
string_scanner:
dependency: transitive
description:
@@ -1457,14 +1481,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.4.0"
- unicode:
- dependency: transitive
- description:
- name: unicode
- sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57
- url: "https://pub.dev"
- source: hosted
- version: "1.1.9"
url_launcher:
dependency: "direct main"
description:
@@ -1641,14 +1657,6 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.0"
- wkt_parser:
- dependency: transitive
- description:
- name: wkt_parser
- sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13"
- url: "https://pub.dev"
- source: hosted
- version: "2.0.0"
xdg_directories:
dependency: transitive
description:
@@ -1674,5 +1682,5 @@ packages:
source: hosted
version: "3.1.3"
sdks:
- dart: ">=3.12.0 <4.0.0"
- flutter: ">=3.44.0"
+ dart: ">=3.13.0 <4.0.0"
+ flutter: ">=3.47.0"
diff --git a/app/pubspec.yaml b/app/pubspec.yaml
index fc765fa..6183065 100644
--- a/app/pubspec.yaml
+++ b/app/pubspec.yaml
@@ -119,12 +119,11 @@ dependencies:
# Firebase OIDC 웹 흐름이 카카오톡 앱 전환 뒤 「missing initial state」로 깨져서 옮겼다.
# 최소 OS 하한 영향 없음: Android minSdk 24(앱 29) · iOS 13.0(앱 15.0).
kakao_flutter_sdk_user: ^2.0.1
- # 위치 Quest 생성 「위치 선택」 지도 · 핀(Figma `371:356705`). flutter_map 은 순수 Dart 라 네이티브 SDK ·
- # API 키가 없다. 타일은 OpenStreetMap 공개 타일 — 운영 트래픽 전 타일 공급원 확정이 필요하다(보고서).
- # 핀 주소 표시는 단말 지오코더(geocoding — iOS CLGeocoder · Android Geocoder, 키 없음).
- # 최소 OS 하한 영향 없음: geocoding_android minSdk 24(앱 29) · geocoding_darwin iOS 12.0(앱 15.0).
- flutter_map: ^8.3.2
- latlong2: ^0.10.1
+ # 위치 Quest 생성 「위치 선택」 지도 · 핀(Figma `371:356705`). 공식 구글 지도 SDK — API 키는 저장소에
+ # 없다(BUILD.md 「구글 지도 API 키」). 핀 주소 표시는 단말 지오코더(geocoding — iOS CLGeocoder ·
+ # Android Geocoder, 키 없음).
+ # 최소 OS 하한: google_maps_flutter_android minSdk 21 · ios iOS 14.0 — 앱(29 · 15.0) 안쪽이라 영향 없다.
+ google_maps_flutter: ^2.18.2
geocoding: ^5.0.0
# 만보기 걸음 수 — Health Connect(Android) · HealthKit(iOS) 의 그 날 0시부터 합계(견적서 「만보기(Health
# API)」 · 대조표 B-02). 2주차 선검증리포트 §2 가 채택한 13.3.1 그대로다. 못 쓰면 위 pedometer 로 센다(B-26).
@@ -202,5 +201,7 @@ flutter:
- assets/galaxy/
# Figma Iconset 아이콘(lib/core/ui/app_icons.dart).
- assets/icons/
+ # 위치 선택 지도 어두운 스타일(Figma `371:356705`).
+ - assets/map/
# 발주사 로고 마크(인수 자산 `fanuzu logo only.png` 에서 마크만 자름) — 로그인 · 가입 완료 · 소개.
- assets/brand/fanuzu-mark.png
diff --git a/app/test/design_match/c_test.dart b/app/test/design_match/c_test.dart
index 14e0ab1..41fa620 100644
--- a/app/test/design_match/c_test.dart
+++ b/app/test/design_match/c_test.dart
@@ -112,7 +112,7 @@ final shots = [
),
),
Shot('F4-04', build: (r) async => QuestCreateScreen(repository: _F4Quests.all())),
- Shot('F4-05', build: (r) async => const PlacePickerScreen()),
+ Shot('F4-05', build: (r) async => PlacePickerScreen(mapBuilder: (_) => const SizedBox.expand())),
Shot(
'F4-06',
build: (r) async => QuestListScreen(repository: _F4Quests.all(), myPop: 12540),
diff --git a/app/test/f4_screens_test.dart b/app/test/f4_screens_test.dart
index 0f7c19f..d3ec1aa 100644
--- a/app/test/f4_screens_test.dart
+++ b/app/test/f4_screens_test.dart
@@ -1,6 +1,7 @@
import 'package:fancake/core/data/backend.dart';
import 'package:fancake/core/monetization/rewarded_ad.dart';
import 'package:fancake/features/quest/place_picker_screen.dart';
+import 'package:google_maps_flutter/google_maps_flutter.dart' show LatLng;
import 'package:fancake/features/quest/quest_ad_screens.dart';
import 'package:fancake/features/quest/quest_create_screen.dart';
import 'package:fancake/features/quest/quest_data.dart';
@@ -20,6 +21,14 @@ import 'package:flutter_test/flutter_test.dart';
/// 몫이고, 화면은 주입받은 것을 그린다.
const _sample = SampleQuestRepository();
+/// 지도 플랫폼 뷰 대신 — 콜백만 붙잡아 테스트가 지도 이동을 흉내 낸다.
+Future _noLastKnown() async => null;
+PickerMapArgs? _map;
+Widget _fakeMap(PickerMapArgs a) {
+ _map = a;
+ return const SizedBox.expand();
+}
+
Future pumpWide(WidgetTester tester, Widget child) async {
tester.view.physicalSize = const Size(1080, 2400);
tester.view.devicePixelRatio = 3.0;
@@ -406,7 +415,7 @@ void main() {
expect(find.textContaining('목표 위치'), findsOneWidget);
});
- testWidgets('위치 분류에서는 장소를 고르기 전까지 완료가 열리지 않는다', (tester) async {
+ testWidgets('위치 분류에서는 장소를 고르기 전까지 완료가 열리지 않는다(위치 선택 화면 열림 · 닫아도 잠김)', (tester) async {
await pumpWide(tester, const QuestCreateScreen(repository: _sample));
await tester.pump();
await tester.tap(find.text('특정 위치'));
@@ -424,13 +433,11 @@ void main() {
await tester.tap(find.textContaining('목표 위치'));
await tester.pumpAndSettle();
expect(find.byType(PlacePickerScreen), findsOneWidget);
- await tester.tap(find.text('홍대 팝업스토어'));
+ // 위치를 정하지 않고 닫으면 장소가 비어 완료는 그대로 잠겨 있다(선택 값은 아래 위치 선택 테스트).
+ await tester.tap(find.byType(CloseButton));
await tester.pumpAndSettle();
- await tester.tap(find.text('선택 완료'));
- await tester.pumpAndSettle();
-
done = tester.widget(find.widgetWithText(TextButton, '완료'));
- expect(done.onPressed, isNotNull);
+ expect(done.onPressed, isNull);
});
testWidgets('완료를 누르면 만들기 전에 되묻는다', (tester) async {
@@ -494,26 +501,71 @@ void main() {
group('위치 선택 (F4 · 371:356705)', () {
testWidgets('고르기 전에는 확정 버튼이 비활성이다', (tester) async {
- await pumpWide(tester, const PlacePickerScreen());
+ await pumpWide(tester, PlacePickerScreen(mapBuilder: _fakeMap, lastKnown: _noLastKnown));
final button = tester.widget(
find.widgetWithText(FilledButton, '선택 완료'),
);
expect(button.onPressed, isNull);
});
- testWidgets('검색으로 목록이 줄어든다', (tester) async {
- await pumpWide(tester, const PlacePickerScreen());
- await tester.enterText(find.byType(TextField), '홍대');
+ testWidgets('지도를 움직이면 주소 조회 중에는 잠기고 끝나면 가운데 좌표를 돌려준다', (tester) async {
+ QuestPlace? result;
+ await pumpWide(
+ tester,
+ Builder(
+ builder: (context) => TextButton(
+ onPressed: () async => result = await Navigator.of(context).push(
+ MaterialPageRoute(
+ builder: (_) => PlacePickerScreen(mapBuilder: _fakeMap, lastKnown: _noLastKnown),
+ ),
+ ),
+ child: const Text('열기'),
+ ),
+ ),
+ );
+ await tester.tap(find.text('열기'));
+ await tester.pumpAndSettle();
+ FilledButton done() => tester.widget(
+ find.widgetWithText(FilledButton, '선택 완료'),
+ );
+ _map!.onMoveStarted();
await tester.pump();
- expect(find.text('홍대 팝업스토어'), findsOneWidget);
- expect(find.text('고척 스카이돔'), findsNothing);
+ expect(done().onPressed, isNull);
+ _map!.onMove(const LatLng(37.55, 126.92));
+ _map!.onIdle();
+ await tester.pump();
+ await tester.pump();
+ expect(done().onPressed, isNotNull);
+ await tester.tap(find.widgetWithText(FilledButton, '선택 완료'));
+ await tester.pumpAndSettle();
+ expect(result?.lat, 37.55);
+ expect(result?.lng, 126.92);
});
- testWidgets('없는 장소를 찾으면 빈 상태를 알린다', (tester) async {
- await pumpWide(tester, const PlacePickerScreen());
- await tester.enterText(find.byType(TextField), '없는장소');
- await tester.pump();
- expect(find.text('검색 결과가 없습니다.'), findsOneWidget);
+ testWidgets('Figma 에 없는 장소 검색 · 등록 장소 목록은 두지 않는다', (tester) async {
+ await pumpWide(tester, PlacePickerScreen(mapBuilder: _fakeMap, lastKnown: _noLastKnown));
+ await tester.pumpAndSettle();
+ expect(find.byType(TextField), findsNothing);
+ expect(find.byType(RadioListTile), findsNothing);
+ });
+
+ testWidgets('현재 위치 확정 전에는 등록 장소가 아니라 마지막 위치(없으면 기본 위치)로 시작한다', (tester) async {
+ _map = null;
+ await pumpWide(tester, PlacePickerScreen(mapBuilder: _fakeMap, lastKnown: _noLastKnown));
+ await tester.pumpAndSettle();
+ expect(_map!.initial, PlacePickerScreen.fallbackCenter);
+
+ _map = null;
+ await pumpWide(
+ tester,
+ PlacePickerScreen(
+ key: UniqueKey(),
+ mapBuilder: _fakeMap,
+ lastKnown: () async => const LatLng(35.1, 129.0),
+ ),
+ );
+ await tester.pumpAndSettle();
+ expect(_map!.initial, const LatLng(35.1, 129.0));
});
});
@@ -566,7 +618,7 @@ void main() {
const QuestListScreen(repository: _sample),
const QuestDetailScreen(questId: 'q1', repository: _sample),
const QuestCreateScreen(repository: _sample),
- const PlacePickerScreen(),
+ PlacePickerScreen(mapBuilder: _fakeMap, lastKnown: _noLastKnown),
]) {
await pumpWide(tester, screen);
await tester.pump();