위치 선택 지도를 구글 지도로 · Figma 구성 맞춤

This commit is contained in:
Sn-Kinos
2026-10-06 10:35:20 +09:00
parent 5269b987e0
commit 4e8d989f29
18 changed files with 401 additions and 244 deletions
+2
View File
@@ -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/
+8
View File
@@ -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` 가 채워지며, 콘솔에서만 켤 수 있다.
+8
View File
@@ -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 {
@@ -83,6 +83,10 @@
<meta-data
android:name="com.google.android.gms.ads.APPLICATION_ID"
android:value="ca-app-pub-3940256099942544~3347511713" />
<!-- 구글 지도 API 키 — android/local.properties 의 MAPS_API_KEY(저장소 밖). 없으면 지도만 빈다. -->
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="${mapsApiKey}" />
<!-- FCM 알림 표시 기본값 — Android 가 유일한 실동작 플랫폼이다(iOS 는 APNs 키
대기). 둘 다 없으면 백그라운드 알림이 「Miscellaneous」 채널로 흘러가고
흰 사각형 아이콘으로 뜬다. 사용자가 채널을 끄면 5개 구분 설정과 무관하게
+17
View File
@@ -0,0 +1,17 @@
[
{"elementType": "geometry", "stylers": [{"color": "#102642"}]},
{"elementType": "labels.icon", "stylers": [{"visibility": "off"}]},
{"elementType": "labels.text.fill", "stylers": [{"color": "#c0cbd8"}]},
{"elementType": "labels.text.stroke", "stylers": [{"color": "#102642"}]},
{"featureType": "administrative", "elementType": "geometry", "stylers": [{"color": "#2e394e"}]},
{"featureType": "poi", "elementType": "geometry", "stylers": [{"color": "#121823"}]},
{"featureType": "poi.park", "elementType": "geometry", "stylers": [{"color": "#004a58"}]},
{"featureType": "poi.park", "elementType": "labels.text.fill", "stylers": [{"color": "#7fca9d"}]},
{"featureType": "landscape.natural", "elementType": "geometry", "stylers": [{"color": "#004a58"}]},
{"featureType": "road", "elementType": "geometry", "stylers": [{"color": "#35516a"}]},
{"featureType": "road.arterial", "elementType": "geometry", "stylers": [{"color": "#4b6d8d"}]},
{"featureType": "road.highway", "elementType": "geometry", "stylers": [{"color": "#be9e00"}]},
{"featureType": "transit", "elementType": "geometry", "stylers": [{"color": "#121823"}]},
{"featureType": "water", "elementType": "geometry", "stylers": [{"color": "#000c2c"}]},
{"featureType": "water", "elementType": "labels.text.fill", "stylers": [{"color": "#7fca9d"}]}
]
+3
View File
@@ -6,6 +6,9 @@
// 빌드가 되어야 하기 때문이다 — 그때는 Google 로그인만 동작하지 않는다.
#include? "../config/Google.xcconfig"
// 구글 지도 API 키(MAPS_API_KEY=...) — Info.plist GMSApiKey → AppDelegate. 없으면 지도만 빈다.
#include? "../config/Maps.xcconfig"
// 카카오 네이티브 앱 키(FANUZU_KAKAO_NATIVE_APP_KEY=...) — Info.plist 의 kakao{키} URL 스킴.
// Dart(--dart-define-from-file)·Android 와 같은 파일이다. 없으면 카카오 로그인만 막힌다.
#include? "../../config/kakao.env"
+3
View File
@@ -6,6 +6,9 @@
// 빌드가 되어야 하기 때문이다 — 그때는 Google 로그인만 동작하지 않는다.
#include? "../config/Google.xcconfig"
// 구글 지도 API 키(MAPS_API_KEY=...) — Info.plist GMSApiKey → AppDelegate. 없으면 지도만 빈다.
#include? "../config/Maps.xcconfig"
// 카카오 네이티브 앱 키(FANUZU_KAKAO_NATIVE_APP_KEY=...) — Info.plist 의 kakao{키} URL 스킴.
// Dart(--dart-define-from-file)·Android 와 같은 파일이다. 없으면 카카오 로그인만 막힌다.
#include? "../../config/kakao.env"
+20
View File
@@ -1,20 +1,40 @@
PODS:
- Flutter (1.0.0)
- Google-Maps-iOS-Utils (6.1.0):
- GoogleMaps (~> 9.0)
- google_maps_flutter_ios (0.0.1):
- Flutter
- Google-Maps-iOS-Utils (< 7.0, >= 5.0)
- GoogleMaps (< 11.0, >= 8.4)
- GoogleMaps (9.4.0):
- GoogleMaps/Maps (= 9.4.0)
- GoogleMaps/Maps (9.4.0)
- health (13.3.1):
- Flutter
DEPENDENCIES:
- Flutter (from `Flutter`)
- google_maps_flutter_ios (from `.symlinks/plugins/google_maps_flutter_ios/ios`)
- health (from `.symlinks/plugins/health/ios`)
SPEC REPOS:
trunk:
- Google-Maps-iOS-Utils
- GoogleMaps
EXTERNAL SOURCES:
Flutter:
:path: Flutter
google_maps_flutter_ios:
:path: ".symlinks/plugins/google_maps_flutter_ios/ios"
health:
:path: ".symlinks/plugins/health/ios"
SPEC CHECKSUMS:
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
Google-Maps-iOS-Utils: 0a484b05ed21d88c9f9ebbacb007956edd508a96
google_maps_flutter_ios: 17552876e72723da1d41accc22f03b5f7afbde69
GoogleMaps: 0608099d4870cac8754bdba9b6953db543432438
health: 4decf5d74e8f54c58a8c07a385fc72f629a831d9
PODFILE CHECKSUM: a9f0c3e5ff2dc240d258357afba1f66fdbe55a54
+18
View File
@@ -209,6 +209,7 @@
9705A1C41CF9048500538489 /* Embed Frameworks */,
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
969D2A0A08424A33F3DBC3C4 /* [CP] Embed Pods Frameworks */,
CD0CE3127E257F61DD7FAF1D /* [CP] Copy Pods Resources */,
);
buildRules = (
);
@@ -379,6 +380,23 @@
shellPath = /bin/sh;
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
};
CD0CE3127E257F61DD7FAF1D /* [CP] Copy Pods Resources */ = {
isa = PBXShellScriptBuildPhase;
buildActionMask = 2147483647;
files = (
);
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
showEnvVarsInLog = 0;
};
/* End PBXShellScriptBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
+5
View File
@@ -1,4 +1,5 @@
import Flutter
import GoogleMaps
import UIKit
@main
@@ -7,6 +8,10 @@ import UIKit
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
// 구글 지도 키 — ios/config/Maps.xcconfig → Info.plist. 없으면 지도만 비고 앱은 정상이다.
if let key = Bundle.main.object(forInfoDictionaryKey: "GMSApiKey") as? String, !key.isEmpty {
GMSServices.provideAPIKey(key)
}
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
+3
View File
@@ -300,6 +300,9 @@
</dict>
</array>
<!-- 카카오톡으로 로그인 — 카카오톡 설치 여부 확인·호출(Kakao Flutter SDK 공식 설정). -->
<key>GMSApiKey</key>
<string>$(MAPS_API_KEY)</string>
<key>LSApplicationQueriesSchemes</key>
<array>
<string>kakaokompassauth</string>
+2
View File
@@ -0,0 +1,2 @@
// 구글 지도 API 키. 이 파일을 Maps.xcconfig 로 복사해 키를 넣는다(Maps.xcconfig 는 gitignore).
MAPS_API_KEY = 여기에_키
+129 -120
View File
@@ -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<QuestPlace> places;
/// 지도 위젯 — 위젯 테스트는 플랫폼 뷰가 없어 가짜로 바꾼다.
final PickerMapBuilder mapBuilder;
/// 마지막으로 알려진 단말 위치(없으면 null) — 테스트는 플랫폼 채널이 없어 바꾼다.
final Future<LatLng?> 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<PlacePickerScreen> {
final _map = MapController();
String _query = '';
GoogleMapController? _map;
/// 지도 가운데 좌표(마지막으로 안 값).
LatLng? _camera;
int _lookups = 0;
QuestPlace? _selected;
/// 지도를 처음 비추는 곳 — 마지막으로 알려진 위치를 읽기 전에는 지도를 만들지 않는다(null).
/// 현재 위치가 확정되기 전에 엉뚱한 곳이 비치지 않게 한다.
LatLng? _start;
/// 가운데 좌표의 주소를 찾는 중 — 이 동안은 *선택 완료* 를 잠근다(명세 「정상 호출 후 활성화」).
bool _resolving = false;
Timer? _settle;
List<QuestPlace> 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<void> _startFromLastKnown() async {
var start = PlacePickerScreen.fallbackCenter;
try {
start = await widget.lastKnown() ?? start;
} catch (_) {
// 플랫폼 구현 없음(테스트) · 미지원 — 기본 위치로 시작.
}
if (!mounted) return;
// 현재 위치가 먼저 와 카메라가 옮겨졌다면 그 자리에서 시작한다.
setState(() => _start = _camera ?? start);
}
/// 디폴트 사용자 현재 위치. 권한이 없거나 꺼져 있으면 지금 화면(마지막 위치 · 서울시청)에 둔다 —
/// 탐색은 권한 없이 열린다(B-27). 이때는 사용자가 지도를 움직여야 위치가 정해진다.
Future<void> _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<PlacePickerScreen> {
);
});
var address = coords;
_lookups++;
try {
final marks = await Geocoding().placemarkFromCoordinates(
point.latitude,
@@ -133,6 +153,7 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
} catch (_) {
// 지오코더가 없거나(에뮬레이터 · 테스트 — 플랫폼 구현 없음) 실패 — 좌표 표시로 둔다.
}
_lookups--;
if (!mounted || _selected?.lat != point.latitude) return;
setState(() {
_resolving = false;
@@ -182,37 +203,19 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
],
),
),
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,
if (_start != null)
widget.mapBuilder(
PickerMapArgs(
initial: _start!,
onCreated: (c) => _map = c,
onMoveStarted: _moveStarted,
onMove: (p) => _camera = p,
onIdle: _idle,
),
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,
),
),
),
],
),
// 위치 마크 — *지도 영역 중간 정렬*. 핀 끝이 지도 가운데(선택 좌표)를 가리킨다.
const IgnorePointer(
@@ -227,54 +230,9 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
),
),
),
// OSM 타일 이용 조건 — 출처 표기.
const Align(
alignment: Alignment.bottomRight,
child: Text(
'© OpenStreetMap',
style: TextStyle(fontSize: 10, color: Colors.white54),
),
),
],
),
),
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<String>(
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<String>(
value: place.id,
title: Text(place.name),
secondary: const Icon(Icons.place_outlined),
);
},
),
),
),
SafeArea(
Align(
alignment: Alignment.bottomCenter,
child: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
@@ -300,12 +258,63 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
),
),
),
),
],
),
),
],
),
);
}
}
/// 지도 위젯이 받는 값 — [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<GoogleMapController> onCreated;
final VoidCallback onMoveStarted;
final ValueChanged<LatLng> onMove;
final VoidCallback onIdle;
}
typedef PickerMapBuilder = Widget Function(PickerMapArgs args);
Future<LatLng?> _deviceLastKnown() async {
final p = await Geolocator.getLastKnownPosition();
return p == null ? null : LatLng(p.latitude, p.longitude);
}
/// Figma `371:356705` 어두운 지도 스타일(JSON 에셋). 한 번 읽어 둔다.
final Future<String> _mapStyle = rootBundle.loadString('assets/map/dark_style.json');
Widget _googleMap(PickerMapArgs a) => FutureBuilder<String>(
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<OneSequenceGestureRecognizer>(EagerGestureRecognizer.new),
},
),
);
/// 지오코더 결과 → 한 줄 주소. 한국 주소는 도로명이 `thoroughfare` 에 오지 않아(번지만 `subThoroughfare`)
/// 칸을 이어 붙이면 「서울특별시 마포구 34」가 된다(10/6 실기기). 도로명이 든 `street` 를 쓴다 —
/// Android 는 주소 전체 줄(나라 이름 포함), iOS 는 「도로명 번지」만 준다.
@@ -46,7 +46,6 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
QuestCatalog? _catalog;
QuestCategory? _category;
QuestActivity? _activity;
List<QuestPlace> _places = const [];
QuestPlace? _place;
DateTime? _endDate;
QuestLink? _link;
@@ -442,14 +441,9 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
final _extraVerify = <String>{};
Future<void> _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<QuestPlace>(
// 명세 `523:18207` — 선택 완료 시 *하단 move out 페이지 종료 후 이전 화면 이동*.
SheetRoute(builder: (_) => PlacePickerScreen(places: _places)),
SheetRoute(builder: (_) => const PlacePickerScreen()),
);
if (picked == null || !mounted) return;
setState(() => _place = picked);
+78 -70
View File
@@ -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"
+7 -6
View File
@@ -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
+1 -1
View File
@@ -112,7 +112,7 @@ final shots = <Shot>[
),
),
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),
+70 -18
View File
@@ -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<LatLng?> _noLastKnown() async => null;
PickerMapArgs? _map;
Widget _fakeMap(PickerMapArgs a) {
_map = a;
return const SizedBox.expand();
}
Future<void> 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<TextButton>(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<FilledButton>(
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<QuestPlace>(
builder: (_) => PlacePickerScreen(mapBuilder: _fakeMap, lastKnown: _noLastKnown),
),
),
child: const Text('열기'),
),
),
);
await tester.tap(find.text('열기'));
await tester.pumpAndSettle();
FilledButton done() => tester.widget<FilledButton>(
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<String>), 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();