From 4e8d989f29e542a40dfef03903a576dc05be122f Mon Sep 17 00:00:00 2001 From: Sn-Kinos Date: Tue, 6 Oct 2026 10:35:20 +0900 Subject: [PATCH] =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EC=A7=80=EB=8F=84=EB=A5=BC=20=EA=B5=AC=EA=B8=80=20=EC=A7=80?= =?UTF-8?q?=EB=8F=84=EB=A1=9C=20=C2=B7=20Figma=20=EA=B5=AC=EC=84=B1=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/.gitignore | 2 + app/BUILD.md | 8 + app/android/app/build.gradle.kts | 8 + app/android/app/src/main/AndroidManifest.xml | 4 + app/assets/map/dark_style.json | 17 + app/ios/Flutter/Debug.xcconfig | 3 + app/ios/Flutter/Release.xcconfig | 3 + app/ios/Podfile.lock | 20 ++ app/ios/Runner.xcodeproj/project.pbxproj | 18 ++ app/ios/Runner/AppDelegate.swift | 5 + app/ios/Runner/Info.plist | 3 + app/ios/config/Maps.xcconfig.example | 2 + .../features/quest/place_picker_screen.dart | 293 +++++++++--------- .../features/quest/quest_create_screen.dart | 8 +- app/pubspec.lock | 148 ++++----- app/pubspec.yaml | 13 +- app/test/design_match/c_test.dart | 2 +- app/test/f4_screens_test.dart | 88 ++++-- 18 files changed, 401 insertions(+), 244 deletions(-) create mode 100644 app/assets/map/dark_style.json create mode 100644 app/ios/config/Maps.xcconfig.example 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();