위치 선택 지도를 구글 지도로 · Figma 구성 맞춤
This commit is contained in:
@@ -57,6 +57,8 @@ key.properties
|
|||||||
**/google-services.json
|
**/google-services.json
|
||||||
# GoogleService-Info.plist 에서 생성한다 — tools/ios/inject_url_scheme.py
|
# GoogleService-Info.plist 에서 생성한다 — tools/ios/inject_url_scheme.py
|
||||||
ios/config/Google.xcconfig
|
ios/config/Google.xcconfig
|
||||||
|
# 구글 지도 API 키 — BUILD.md 「구글 지도 API 키」
|
||||||
|
ios/config/Maps.xcconfig
|
||||||
|
|
||||||
# FVM Version Cache
|
# FVM Version Cache
|
||||||
.fvm/
|
.fvm/
|
||||||
|
|||||||
@@ -145,6 +145,14 @@ Dart 는 실행·빌드 때 `--dart-define-from-file=config/kakao.env` 로 받
|
|||||||
「설정 전」으로 막힌다. 서버 `fnAuthKakao` 는 카카오 **앱 ID**(숫자)를 `firebase/functions/.env.<프로젝트>`
|
「설정 전」으로 막힌다. 서버 `fnAuthKakao` 는 카카오 **앱 ID**(숫자)를 `firebase/functions/.env.<프로젝트>`
|
||||||
의 `KAKAO_APP_ID` 로 받는다.
|
의 `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 을
|
Authentication 제공자는 이메일/비밀번호와 Google 둘 다 켜져 있다(2026-09-11). Google 을
|
||||||
켜야 `oauth_client` 와 `REVERSED_CLIENT_ID` 가 채워지며, 콘솔에서만 켤 수 있다.
|
켜야 `oauth_client` 와 `REVERSED_CLIENT_ID` 가 채워지며, 콘솔에서만 켤 수 있다.
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,13 @@ val kakaoProperties = Properties().apply {
|
|||||||
if (f.exists()) f.inputStream().use { load(it) }
|
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 등)을 요구하는가.
|
/// 이번 실행이 릴리스 산출물(assembleRelease · bundleRelease 등)을 요구하는가.
|
||||||
val wantsRelease = gradle.startParameter.taskNames.any { it.contains("Release", ignoreCase = true) }
|
val wantsRelease = gradle.startParameter.taskNames.any { it.contains("Release", ignoreCase = true) }
|
||||||
|
|
||||||
@@ -64,6 +71,7 @@ android {
|
|||||||
// AndroidManifest 의 카카오 로그인 리다이렉트 스킴 `kakao{네이티브 앱 키}`.
|
// AndroidManifest 의 카카오 로그인 리다이렉트 스킴 `kakao{네이티브 앱 키}`.
|
||||||
manifestPlaceholders["kakaoNativeAppKey"] =
|
manifestPlaceholders["kakaoNativeAppKey"] =
|
||||||
kakaoProperties.getProperty("FANUZU_KAKAO_NATIVE_APP_KEY", "")
|
kakaoProperties.getProperty("FANUZU_KAKAO_NATIVE_APP_KEY", "")
|
||||||
|
manifestPlaceholders["mapsApiKey"] = localProperties.getProperty("MAPS_API_KEY", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -83,6 +83,10 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
android:name="com.google.android.gms.ads.APPLICATION_ID"
|
||||||
android:value="ca-app-pub-3940256099942544~3347511713" />
|
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 키
|
<!-- FCM 알림 표시 기본값 — Android 가 유일한 실동작 플랫폼이다(iOS 는 APNs 키
|
||||||
대기). 둘 다 없으면 백그라운드 알림이 「Miscellaneous」 채널로 흘러가고
|
대기). 둘 다 없으면 백그라운드 알림이 「Miscellaneous」 채널로 흘러가고
|
||||||
흰 사각형 아이콘으로 뜬다. 사용자가 채널을 끄면 5개 구분 설정과 무관하게
|
흰 사각형 아이콘으로 뜬다. 사용자가 채널을 끄면 5개 구분 설정과 무관하게
|
||||||
|
|||||||
@@ -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"}]}
|
||||||
|
]
|
||||||
@@ -6,6 +6,9 @@
|
|||||||
// 빌드가 되어야 하기 때문이다 — 그때는 Google 로그인만 동작하지 않는다.
|
// 빌드가 되어야 하기 때문이다 — 그때는 Google 로그인만 동작하지 않는다.
|
||||||
#include? "../config/Google.xcconfig"
|
#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 스킴.
|
// 카카오 네이티브 앱 키(FANUZU_KAKAO_NATIVE_APP_KEY=...) — Info.plist 의 kakao{키} URL 스킴.
|
||||||
// Dart(--dart-define-from-file)·Android 와 같은 파일이다. 없으면 카카오 로그인만 막힌다.
|
// Dart(--dart-define-from-file)·Android 와 같은 파일이다. 없으면 카카오 로그인만 막힌다.
|
||||||
#include? "../../config/kakao.env"
|
#include? "../../config/kakao.env"
|
||||||
|
|||||||
@@ -6,6 +6,9 @@
|
|||||||
// 빌드가 되어야 하기 때문이다 — 그때는 Google 로그인만 동작하지 않는다.
|
// 빌드가 되어야 하기 때문이다 — 그때는 Google 로그인만 동작하지 않는다.
|
||||||
#include? "../config/Google.xcconfig"
|
#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 스킴.
|
// 카카오 네이티브 앱 키(FANUZU_KAKAO_NATIVE_APP_KEY=...) — Info.plist 의 kakao{키} URL 스킴.
|
||||||
// Dart(--dart-define-from-file)·Android 와 같은 파일이다. 없으면 카카오 로그인만 막힌다.
|
// Dart(--dart-define-from-file)·Android 와 같은 파일이다. 없으면 카카오 로그인만 막힌다.
|
||||||
#include? "../../config/kakao.env"
|
#include? "../../config/kakao.env"
|
||||||
|
|||||||
@@ -1,20 +1,40 @@
|
|||||||
PODS:
|
PODS:
|
||||||
- Flutter (1.0.0)
|
- 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):
|
- health (13.3.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
|
|
||||||
DEPENDENCIES:
|
DEPENDENCIES:
|
||||||
- Flutter (from `Flutter`)
|
- Flutter (from `Flutter`)
|
||||||
|
- google_maps_flutter_ios (from `.symlinks/plugins/google_maps_flutter_ios/ios`)
|
||||||
- health (from `.symlinks/plugins/health/ios`)
|
- health (from `.symlinks/plugins/health/ios`)
|
||||||
|
|
||||||
|
SPEC REPOS:
|
||||||
|
trunk:
|
||||||
|
- Google-Maps-iOS-Utils
|
||||||
|
- GoogleMaps
|
||||||
|
|
||||||
EXTERNAL SOURCES:
|
EXTERNAL SOURCES:
|
||||||
Flutter:
|
Flutter:
|
||||||
:path: Flutter
|
:path: Flutter
|
||||||
|
google_maps_flutter_ios:
|
||||||
|
:path: ".symlinks/plugins/google_maps_flutter_ios/ios"
|
||||||
health:
|
health:
|
||||||
:path: ".symlinks/plugins/health/ios"
|
:path: ".symlinks/plugins/health/ios"
|
||||||
|
|
||||||
SPEC CHECKSUMS:
|
SPEC CHECKSUMS:
|
||||||
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
|
Flutter: 71a624a5bc0c04062bf19101d501e466baf2fb47
|
||||||
|
Google-Maps-iOS-Utils: 0a484b05ed21d88c9f9ebbacb007956edd508a96
|
||||||
|
google_maps_flutter_ios: 17552876e72723da1d41accc22f03b5f7afbde69
|
||||||
|
GoogleMaps: 0608099d4870cac8754bdba9b6953db543432438
|
||||||
health: 4decf5d74e8f54c58a8c07a385fc72f629a831d9
|
health: 4decf5d74e8f54c58a8c07a385fc72f629a831d9
|
||||||
|
|
||||||
PODFILE CHECKSUM: a9f0c3e5ff2dc240d258357afba1f66fdbe55a54
|
PODFILE CHECKSUM: a9f0c3e5ff2dc240d258357afba1f66fdbe55a54
|
||||||
|
|||||||
@@ -209,6 +209,7 @@
|
|||||||
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
9705A1C41CF9048500538489 /* Embed Frameworks */,
|
||||||
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
3B06AD1E1E4923F5004D2608 /* Thin Binary */,
|
||||||
969D2A0A08424A33F3DBC3C4 /* [CP] Embed Pods Frameworks */,
|
969D2A0A08424A33F3DBC3C4 /* [CP] Embed Pods Frameworks */,
|
||||||
|
CD0CE3127E257F61DD7FAF1D /* [CP] Copy Pods Resources */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -379,6 +380,23 @@
|
|||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build";
|
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 */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
/* Begin PBXSourcesBuildPhase section */
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Flutter
|
import Flutter
|
||||||
|
import GoogleMaps
|
||||||
import UIKit
|
import UIKit
|
||||||
|
|
||||||
@main
|
@main
|
||||||
@@ -7,6 +8,10 @@ import UIKit
|
|||||||
_ application: UIApplication,
|
_ application: UIApplication,
|
||||||
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
|
||||||
) -> Bool {
|
) -> 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)
|
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -300,6 +300,9 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<!-- 카카오톡으로 로그인 — 카카오톡 설치 여부 확인·호출(Kakao Flutter SDK 공식 설정). -->
|
<!-- 카카오톡으로 로그인 — 카카오톡 설치 여부 확인·호출(Kakao Flutter SDK 공식 설정). -->
|
||||||
|
<key>GMSApiKey</key>
|
||||||
|
<string>$(MAPS_API_KEY)</string>
|
||||||
|
|
||||||
<key>LSApplicationQueriesSchemes</key>
|
<key>LSApplicationQueriesSchemes</key>
|
||||||
<array>
|
<array>
|
||||||
<string>kakaokompassauth</string>
|
<string>kakaokompassauth</string>
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// 구글 지도 API 키. 이 파일을 Maps.xcconfig 로 복사해 키를 넣는다(Maps.xcconfig 는 gitignore).
|
||||||
|
MAPS_API_KEY = 여기에_키
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import 'dart:async';
|
|
||||||
import 'dart:ui' as ui;
|
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/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter/services.dart' show rootBundle;
|
||||||
import 'package:geocoding/geocoding.dart';
|
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/theme/tokens.dart';
|
||||||
import '../../core/i18n/app_locale.dart';
|
import '../../core/i18n/app_locale.dart';
|
||||||
@@ -15,15 +17,22 @@ import 'quest_evidence.dart';
|
|||||||
///
|
///
|
||||||
/// 명세 `523:18207` — 지도는 *디폴트 사용자 현재 위치*, 위치 마크는 *지도 영역 중간 정렬*, 지도를
|
/// 명세 `523:18207` — 지도는 *디폴트 사용자 현재 위치*, 위치 마크는 *지도 영역 중간 정렬*, 지도를
|
||||||
/// 움직이면 가운데 좌표의 주소를 *실시간* 으로 보이고, *선택 완료* 는 위치 정보를 정상 호출한 뒤 켜진다.
|
/// 움직이면 가운데 좌표의 주소를 *실시간* 으로 보이고, *선택 완료* 는 위치 정보를 정상 호출한 뒤 켜진다.
|
||||||
/// 운영이 등록한 장소는 지도 위 표시와
|
/// 장소 검색 · 등록 장소 목록 · 등록 장소 핀은 Figma 와 설명 프레임 어디에도 없어 두지 않는다
|
||||||
/// 아래 목록으로 함께 보이고, 고르면 그 장소를 쓴다(해석 확인 요청 영역 B — 장소 등록 주체 권고안:
|
/// (해석 확인 요청 영역 B — Q-B-03 장소 등록 주체). 보상 인증 반경은 서버가 B-27(기본 100m)로 판정한다.
|
||||||
/// 사용자 핀 → 장소 생성 · 운영 장소 우선 노출). 보상 인증 반경은 서버가 B-27(기본 100m)로 판정한다.
|
|
||||||
///
|
///
|
||||||
/// 돌려주는 값: 등록 장소면 그 [QuestPlace], 핀이면 `id` 가 빈 [QuestPlace](좌표 · 주소).
|
/// 돌려주는 값: 핀이 가리키는 `id` 가 빈 [QuestPlace](좌표 · 주소).
|
||||||
class PlacePickerScreen extends StatefulWidget {
|
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 = [
|
static const defaultPlaces = [
|
||||||
@@ -34,7 +43,7 @@ class PlacePickerScreen extends StatefulWidget {
|
|||||||
QuestPlace(id: 'gocheok', name: '고척 스카이돔'),
|
QuestPlace(id: 'gocheok', name: '고척 스카이돔'),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// 등록 장소 좌표가 하나도 없을 때 지도를 처음 비추는 곳(서울시청). 화면 위치일 뿐 판정값이 아니다.
|
/// 현재 · 마지막 위치를 얻지 못했을 때 지도를 처음 비추는 곳(서울시청). 화면 위치일 뿐 판정값이 아니다.
|
||||||
static const fallbackCenter = LatLng(37.5665, 126.9780);
|
static const fallbackCenter = LatLng(37.5665, 126.9780);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -42,69 +51,79 @@ class PlacePickerScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _PlacePickerScreenState extends State<PlacePickerScreen> {
|
class _PlacePickerScreenState extends State<PlacePickerScreen> {
|
||||||
final _map = MapController();
|
GoogleMapController? _map;
|
||||||
String _query = '';
|
|
||||||
|
/// 지도 가운데 좌표(마지막으로 안 값).
|
||||||
|
LatLng? _camera;
|
||||||
|
int _lookups = 0;
|
||||||
QuestPlace? _selected;
|
QuestPlace? _selected;
|
||||||
|
|
||||||
|
/// 지도를 처음 비추는 곳 — 마지막으로 알려진 위치를 읽기 전에는 지도를 만들지 않는다(null).
|
||||||
|
/// 현재 위치가 확정되기 전에 엉뚱한 곳이 비치지 않게 한다.
|
||||||
|
LatLng? _start;
|
||||||
|
|
||||||
/// 가운데 좌표의 주소를 찾는 중 — 이 동안은 *선택 완료* 를 잠근다(명세 「정상 호출 후 활성화」).
|
/// 가운데 좌표의 주소를 찾는 중 — 이 동안은 *선택 완료* 를 잠근다(명세 「정상 호출 후 활성화」).
|
||||||
bool _resolving = false;
|
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
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_startFromLastKnown();
|
||||||
_locate();
|
_locate();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_settle?.cancel();
|
_map?.dispose();
|
||||||
super.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). 이때는 사용자가 지도를 움직여야 위치가 정해진다.
|
/// 탐색은 권한 없이 열린다(B-27). 이때는 사용자가 지도를 움직여야 위치가 정해진다.
|
||||||
Future<void> _locate() async {
|
Future<void> _locate() async {
|
||||||
try {
|
try {
|
||||||
final fix = await devicePlaceFix();
|
final fix = await devicePlaceFix();
|
||||||
if (!mounted || _selected != null) return;
|
if (!mounted || _selected != null) return;
|
||||||
final here = LatLng(fix.lat, fix.lng);
|
final here = LatLng(fix.lat, fix.lng);
|
||||||
_map.move(here, _map.camera.zoom);
|
_moveTo(here);
|
||||||
await _drop(here);
|
await _drop(here);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// 권한 거부 · 위치 꺼짐 · 플랫폼 구현 없음(테스트) — 기본 화면 유지.
|
// 권한 거부 · 위치 꺼짐 · 플랫폼 구현 없음(테스트) — 기본 화면 유지.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _choose(QuestPlace place) {
|
void _moveTo(LatLng point) {
|
||||||
_settle?.cancel();
|
_camera = point;
|
||||||
setState(() {
|
_map?.moveCamera(CameraUpdate.newLatLng(point));
|
||||||
_selected = place;
|
|
||||||
_resolving = false;
|
|
||||||
});
|
|
||||||
if (place.lat != null && place.lng != null) {
|
|
||||||
_map.move(LatLng(place.lat!, place.lng!), _map.camera.zoom);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 사용자가 지도를 움직이면 멈춘 뒤 가운데 좌표로 위치를 정한다(제스처가 아닌 이동 — 등록 장소
|
/// 사용자가 지도를 움직이기 시작하면 주소 확인 전까지 *선택 완료* 를 잠근다.
|
||||||
/// 선택 · 현재 위치 이동 — 은 무시).
|
void _moveStarted() => setState(() => _resolving = true);
|
||||||
void _moved(MapCamera camera, bool hasGesture) {
|
|
||||||
if (!hasGesture) return;
|
/// 지도가 멈추면 가운데 좌표로 위치를 정한다. 코드가 옮긴 멈춤(현재 위치)은 이미 그
|
||||||
_settle?.cancel();
|
/// 좌표가 선택돼 있으므로 다시 찾지 않는다.
|
||||||
setState(() => _resolving = true);
|
void _idle() {
|
||||||
_settle = Timer(const Duration(milliseconds: 400), () => _drop(camera.center));
|
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;
|
var address = coords;
|
||||||
|
_lookups++;
|
||||||
try {
|
try {
|
||||||
final marks = await Geocoding().placemarkFromCoordinates(
|
final marks = await Geocoding().placemarkFromCoordinates(
|
||||||
point.latitude,
|
point.latitude,
|
||||||
@@ -133,6 +153,7 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
// 지오코더가 없거나(에뮬레이터 · 테스트 — 플랫폼 구현 없음) 실패 — 좌표 표시로 둔다.
|
// 지오코더가 없거나(에뮬레이터 · 테스트 — 플랫폼 구현 없음) 실패 — 좌표 표시로 둔다.
|
||||||
}
|
}
|
||||||
|
_lookups--;
|
||||||
if (!mounted || _selected?.lat != point.latitude) return;
|
if (!mounted || _selected?.lat != point.latitude) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
_resolving = false;
|
_resolving = false;
|
||||||
@@ -182,38 +203,20 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SizedBox(
|
// 시안 `371:356705` — 지도가 남은 높이를 채우고 「선택 완료」가 그 위에 얹힌다.
|
||||||
height: 300,
|
Expanded(
|
||||||
child: FlutterMap(
|
child: Stack(
|
||||||
mapController: _map,
|
|
||||||
options: MapOptions(
|
|
||||||
initialCenter: _center,
|
|
||||||
initialZoom: 15,
|
|
||||||
onPositionChanged: _moved,
|
|
||||||
),
|
|
||||||
children: [
|
children: [
|
||||||
TileLayer(
|
if (_start != null)
|
||||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
widget.mapBuilder(
|
||||||
userAgentPackageName: 'kr.co.fanuzu.app',
|
PickerMapArgs(
|
||||||
// Figma `371:356705` 은 어두운 지도다 — 같은 OSM 타일을 어둡게 칠한다(flutter_map 내장).
|
initial: _start!,
|
||||||
tileBuilder: darkModeTileBuilder,
|
onCreated: (c) => _map = c,
|
||||||
),
|
onMoveStarted: _moveStarted,
|
||||||
MarkerLayer(
|
onMove: (p) => _camera = p,
|
||||||
markers: [
|
onIdle: _idle,
|
||||||
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(
|
const IgnorePointer(
|
||||||
child: Align(
|
child: Align(
|
||||||
@@ -227,85 +230,91 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// OSM 타일 이용 조건 — 출처 표기.
|
Align(
|
||||||
const Align(
|
alignment: Alignment.bottomCenter,
|
||||||
alignment: Alignment.bottomRight,
|
child: SafeArea(
|
||||||
child: Text(
|
top: false,
|
||||||
'© OpenStreetMap',
|
child: Padding(
|
||||||
style: TextStyle(fontSize: 10, color: Colors.white54),
|
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<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(
|
|
||||||
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<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`)
|
/// 지오코더 결과 → 한 줄 주소. 한국 주소는 도로명이 `thoroughfare` 에 오지 않아(번지만 `subThoroughfare`)
|
||||||
/// 칸을 이어 붙이면 「서울특별시 마포구 34」가 된다(10/6 실기기). 도로명이 든 `street` 를 쓴다 —
|
/// 칸을 이어 붙이면 「서울특별시 마포구 34」가 된다(10/6 실기기). 도로명이 든 `street` 를 쓴다 —
|
||||||
/// Android 는 주소 전체 줄(나라 이름 포함), iOS 는 「도로명 번지」만 준다.
|
/// Android 는 주소 전체 줄(나라 이름 포함), iOS 는 「도로명 번지」만 준다.
|
||||||
|
|||||||
@@ -46,7 +46,6 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
|||||||
QuestCatalog? _catalog;
|
QuestCatalog? _catalog;
|
||||||
QuestCategory? _category;
|
QuestCategory? _category;
|
||||||
QuestActivity? _activity;
|
QuestActivity? _activity;
|
||||||
List<QuestPlace> _places = const [];
|
|
||||||
QuestPlace? _place;
|
QuestPlace? _place;
|
||||||
DateTime? _endDate;
|
DateTime? _endDate;
|
||||||
QuestLink? _link;
|
QuestLink? _link;
|
||||||
@@ -442,14 +441,9 @@ class _QuestCreateScreenState extends State<QuestCreateScreen> {
|
|||||||
final _extraVerify = <String>{};
|
final _extraVerify = <String>{};
|
||||||
|
|
||||||
Future<void> _pickPlace() async {
|
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>(
|
final picked = await Navigator.of(context, rootNavigator: true).push<QuestPlace>(
|
||||||
// 명세 `523:18207` — 선택 완료 시 *하단 move out 페이지 종료 후 이전 화면 이동*.
|
// 명세 `523:18207` — 선택 완료 시 *하단 move out 페이지 종료 후 이전 화면 이동*.
|
||||||
SheetRoute(builder: (_) => PlacePickerScreen(places: _places)),
|
SheetRoute(builder: (_) => const PlacePickerScreen()),
|
||||||
);
|
);
|
||||||
if (picked == null || !mounted) return;
|
if (picked == null || !mounted) return;
|
||||||
setState(() => _place = picked);
|
setState(() => _place = picked);
|
||||||
|
|||||||
+78
-70
@@ -201,14 +201,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
dart_earcut:
|
csslib:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: dart_earcut
|
name: csslib
|
||||||
sha256: e485001bfc05dcbc437d7bfb666316182e3522d4c3f9668048e004d0eb2ce43b
|
sha256: "09bad715f418841f976c77db72d5398dc1253c21fb9c0c7f0b0b985860b2d58e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.2.0"
|
version: "1.0.2"
|
||||||
dart_jsonwebtoken:
|
dart_jsonwebtoken:
|
||||||
dependency: "direct overridden"
|
dependency: "direct overridden"
|
||||||
description:
|
description:
|
||||||
@@ -217,14 +217,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.17.0"
|
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:
|
device_info_plus:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -496,14 +488,6 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
flutter_plugin_android_lifecycle:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -631,6 +615,54 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.3.3+1"
|
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:
|
google_mobile_ads:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -695,6 +727,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.2.0"
|
version: "2.2.0"
|
||||||
|
html:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: html
|
||||||
|
sha256: "43b67b8f43321ab066817dfac5619596c98bb1b61624e77203bb4351785f9699"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.15.7"
|
||||||
http:
|
http:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -892,14 +932,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.0.1"
|
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:
|
leak_tracker:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -972,14 +1004,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.0"
|
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:
|
mime:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1212,14 +1236,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "5.0.6"
|
version: "5.0.6"
|
||||||
proj4dart:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: proj4dart
|
|
||||||
sha256: ddcedc1f7876e62717de43ab3491e2829bdad0b028261805f94aa080967e5859
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "3.0.0"
|
|
||||||
pub_semver:
|
pub_semver:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1268,6 +1284,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.28.0"
|
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:
|
sensors_plus:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1380,14 +1404,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.0"
|
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:
|
sky_engine:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description: flutter
|
description: flutter
|
||||||
@@ -1417,6 +1433,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.4"
|
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:
|
string_scanner:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1457,14 +1481,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.4.0"
|
version: "1.4.0"
|
||||||
unicode:
|
|
||||||
dependency: transitive
|
|
||||||
description:
|
|
||||||
name: unicode
|
|
||||||
sha256: a6f7bcfc8ea1d5ce1f6c0b1c39117a9919f4953edd9fd7a64090a9796c499b57
|
|
||||||
url: "https://pub.dev"
|
|
||||||
source: hosted
|
|
||||||
version: "1.1.9"
|
|
||||||
url_launcher:
|
url_launcher:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -1641,14 +1657,6 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
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:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1674,5 +1682,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.12.0 <4.0.0"
|
dart: ">=3.13.0 <4.0.0"
|
||||||
flutter: ">=3.44.0"
|
flutter: ">=3.47.0"
|
||||||
|
|||||||
+7
-6
@@ -119,12 +119,11 @@ dependencies:
|
|||||||
# Firebase OIDC 웹 흐름이 카카오톡 앱 전환 뒤 「missing initial state」로 깨져서 옮겼다.
|
# Firebase OIDC 웹 흐름이 카카오톡 앱 전환 뒤 「missing initial state」로 깨져서 옮겼다.
|
||||||
# 최소 OS 하한 영향 없음: Android minSdk 24(앱 29) · iOS 13.0(앱 15.0).
|
# 최소 OS 하한 영향 없음: Android minSdk 24(앱 29) · iOS 13.0(앱 15.0).
|
||||||
kakao_flutter_sdk_user: ^2.0.1
|
kakao_flutter_sdk_user: ^2.0.1
|
||||||
# 위치 Quest 생성 「위치 선택」 지도 · 핀(Figma `371:356705`). flutter_map 은 순수 Dart 라 네이티브 SDK ·
|
# 위치 Quest 생성 「위치 선택」 지도 · 핀(Figma `371:356705`). 공식 구글 지도 SDK — API 키는 저장소에
|
||||||
# API 키가 없다. 타일은 OpenStreetMap 공개 타일 — 운영 트래픽 전 타일 공급원 확정이 필요하다(보고서).
|
# 없다(BUILD.md 「구글 지도 API 키」). 핀 주소 표시는 단말 지오코더(geocoding — iOS CLGeocoder ·
|
||||||
# 핀 주소 표시는 단말 지오코더(geocoding — iOS CLGeocoder · Android Geocoder, 키 없음).
|
# Android Geocoder, 키 없음).
|
||||||
# 최소 OS 하한 영향 없음: geocoding_android minSdk 24(앱 29) · geocoding_darwin iOS 12.0(앱 15.0).
|
# 최소 OS 하한: google_maps_flutter_android minSdk 21 · ios iOS 14.0 — 앱(29 · 15.0) 안쪽이라 영향 없다.
|
||||||
flutter_map: ^8.3.2
|
google_maps_flutter: ^2.18.2
|
||||||
latlong2: ^0.10.1
|
|
||||||
geocoding: ^5.0.0
|
geocoding: ^5.0.0
|
||||||
# 만보기 걸음 수 — Health Connect(Android) · HealthKit(iOS) 의 그 날 0시부터 합계(견적서 「만보기(Health
|
# 만보기 걸음 수 — Health Connect(Android) · HealthKit(iOS) 의 그 날 0시부터 합계(견적서 「만보기(Health
|
||||||
# API)」 · 대조표 B-02). 2주차 선검증리포트 §2 가 채택한 13.3.1 그대로다. 못 쓰면 위 pedometer 로 센다(B-26).
|
# API)」 · 대조표 B-02). 2주차 선검증리포트 §2 가 채택한 13.3.1 그대로다. 못 쓰면 위 pedometer 로 센다(B-26).
|
||||||
@@ -202,5 +201,7 @@ flutter:
|
|||||||
- assets/galaxy/
|
- assets/galaxy/
|
||||||
# Figma Iconset 아이콘(lib/core/ui/app_icons.dart).
|
# Figma Iconset 아이콘(lib/core/ui/app_icons.dart).
|
||||||
- assets/icons/
|
- assets/icons/
|
||||||
|
# 위치 선택 지도 어두운 스타일(Figma `371:356705`).
|
||||||
|
- assets/map/
|
||||||
# 발주사 로고 마크(인수 자산 `fanuzu logo only.png` 에서 마크만 자름) — 로그인 · 가입 완료 · 소개.
|
# 발주사 로고 마크(인수 자산 `fanuzu logo only.png` 에서 마크만 자름) — 로그인 · 가입 완료 · 소개.
|
||||||
- assets/brand/fanuzu-mark.png
|
- assets/brand/fanuzu-mark.png
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ final shots = <Shot>[
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Shot('F4-04', build: (r) async => QuestCreateScreen(repository: _F4Quests.all())),
|
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(
|
Shot(
|
||||||
'F4-06',
|
'F4-06',
|
||||||
build: (r) async => QuestListScreen(repository: _F4Quests.all(), myPop: 12540),
|
build: (r) async => QuestListScreen(repository: _F4Quests.all(), myPop: 12540),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'package:fancake/core/data/backend.dart';
|
import 'package:fancake/core/data/backend.dart';
|
||||||
import 'package:fancake/core/monetization/rewarded_ad.dart';
|
import 'package:fancake/core/monetization/rewarded_ad.dart';
|
||||||
import 'package:fancake/features/quest/place_picker_screen.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_ad_screens.dart';
|
||||||
import 'package:fancake/features/quest/quest_create_screen.dart';
|
import 'package:fancake/features/quest/quest_create_screen.dart';
|
||||||
import 'package:fancake/features/quest/quest_data.dart';
|
import 'package:fancake/features/quest/quest_data.dart';
|
||||||
@@ -20,6 +21,14 @@ import 'package:flutter_test/flutter_test.dart';
|
|||||||
/// 몫이고, 화면은 주입받은 것을 그린다.
|
/// 몫이고, 화면은 주입받은 것을 그린다.
|
||||||
const _sample = SampleQuestRepository();
|
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 {
|
Future<void> pumpWide(WidgetTester tester, Widget child) async {
|
||||||
tester.view.physicalSize = const Size(1080, 2400);
|
tester.view.physicalSize = const Size(1080, 2400);
|
||||||
tester.view.devicePixelRatio = 3.0;
|
tester.view.devicePixelRatio = 3.0;
|
||||||
@@ -406,7 +415,7 @@ void main() {
|
|||||||
expect(find.textContaining('목표 위치'), findsOneWidget);
|
expect(find.textContaining('목표 위치'), findsOneWidget);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('위치 분류에서는 장소를 고르기 전까지 완료가 열리지 않는다', (tester) async {
|
testWidgets('위치 분류에서는 장소를 고르기 전까지 완료가 열리지 않는다(위치 선택 화면 열림 · 닫아도 잠김)', (tester) async {
|
||||||
await pumpWide(tester, const QuestCreateScreen(repository: _sample));
|
await pumpWide(tester, const QuestCreateScreen(repository: _sample));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.tap(find.text('특정 위치'));
|
await tester.tap(find.text('특정 위치'));
|
||||||
@@ -424,13 +433,11 @@ void main() {
|
|||||||
await tester.tap(find.textContaining('목표 위치'));
|
await tester.tap(find.textContaining('목표 위치'));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
expect(find.byType(PlacePickerScreen), findsOneWidget);
|
expect(find.byType(PlacePickerScreen), findsOneWidget);
|
||||||
await tester.tap(find.text('홍대 팝업스토어'));
|
// 위치를 정하지 않고 닫으면 장소가 비어 완료는 그대로 잠겨 있다(선택 값은 아래 위치 선택 테스트).
|
||||||
|
await tester.tap(find.byType(CloseButton));
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
await tester.tap(find.text('선택 완료'));
|
|
||||||
await tester.pumpAndSettle();
|
|
||||||
|
|
||||||
done = tester.widget<TextButton>(find.widgetWithText(TextButton, '완료'));
|
done = tester.widget<TextButton>(find.widgetWithText(TextButton, '완료'));
|
||||||
expect(done.onPressed, isNotNull);
|
expect(done.onPressed, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('완료를 누르면 만들기 전에 되묻는다', (tester) async {
|
testWidgets('완료를 누르면 만들기 전에 되묻는다', (tester) async {
|
||||||
@@ -494,26 +501,71 @@ void main() {
|
|||||||
|
|
||||||
group('위치 선택 (F4 · 371:356705)', () {
|
group('위치 선택 (F4 · 371:356705)', () {
|
||||||
testWidgets('고르기 전에는 확정 버튼이 비활성이다', (tester) async {
|
testWidgets('고르기 전에는 확정 버튼이 비활성이다', (tester) async {
|
||||||
await pumpWide(tester, const PlacePickerScreen());
|
await pumpWide(tester, PlacePickerScreen(mapBuilder: _fakeMap, lastKnown: _noLastKnown));
|
||||||
final button = tester.widget<FilledButton>(
|
final button = tester.widget<FilledButton>(
|
||||||
find.widgetWithText(FilledButton, '선택 완료'),
|
find.widgetWithText(FilledButton, '선택 완료'),
|
||||||
);
|
);
|
||||||
expect(button.onPressed, isNull);
|
expect(button.onPressed, isNull);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('검색으로 목록이 줄어든다', (tester) async {
|
testWidgets('지도를 움직이면 주소 조회 중에는 잠기고 끝나면 가운데 좌표를 돌려준다', (tester) async {
|
||||||
await pumpWide(tester, const PlacePickerScreen());
|
QuestPlace? result;
|
||||||
await tester.enterText(find.byType(TextField), '홍대');
|
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();
|
await tester.pump();
|
||||||
expect(find.text('홍대 팝업스토어'), findsOneWidget);
|
expect(done().onPressed, isNull);
|
||||||
expect(find.text('고척 스카이돔'), findsNothing);
|
_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 {
|
testWidgets('Figma 에 없는 장소 검색 · 등록 장소 목록은 두지 않는다', (tester) async {
|
||||||
await pumpWide(tester, const PlacePickerScreen());
|
await pumpWide(tester, PlacePickerScreen(mapBuilder: _fakeMap, lastKnown: _noLastKnown));
|
||||||
await tester.enterText(find.byType(TextField), '없는장소');
|
await tester.pumpAndSettle();
|
||||||
await tester.pump();
|
expect(find.byType(TextField), findsNothing);
|
||||||
expect(find.text('검색 결과가 없습니다.'), findsOneWidget);
|
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 QuestListScreen(repository: _sample),
|
||||||
const QuestDetailScreen(questId: 'q1', repository: _sample),
|
const QuestDetailScreen(questId: 'q1', repository: _sample),
|
||||||
const QuestCreateScreen(repository: _sample),
|
const QuestCreateScreen(repository: _sample),
|
||||||
const PlacePickerScreen(),
|
PlacePickerScreen(mapBuilder: _fakeMap, lastKnown: _noLastKnown),
|
||||||
]) {
|
]) {
|
||||||
await pumpWide(tester, screen);
|
await pumpWide(tester, screen);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
|||||||
Reference in New Issue
Block a user