390 lines
14 KiB
Dart
390 lines
14 KiB
Dart
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/services.dart' show rootBundle;
|
||
import 'package:geocoding/geocoding.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';
|
||
import 'quest_data.dart';
|
||
import 'quest_evidence.dart';
|
||
|
||
/// 위치 선택 — 화면 대조 원장 F4 · Figma `371:356705`(지도 + 핀 주소 + 「선택 완료」).
|
||
///
|
||
/// 명세 `523:18207` — 지도는 *디폴트 사용자 현재 위치*, 위치 마크는 *지도 영역 중간 정렬*, 지도를
|
||
/// 움직이면 가운데 좌표의 주소를 *실시간* 으로 보이고, *선택 완료* 는 위치 정보를 정상 호출한 뒤 켜진다.
|
||
/// 장소 검색 · 등록 장소 목록 · 등록 장소 핀은 Figma 와 설명 프레임 어디에도 없어 두지 않는다
|
||
/// (해석 확인 요청 영역 B — Q-B-03 장소 등록 주체). 보상 인증 반경은 서버가 B-27(기본 100m)로 판정한다.
|
||
///
|
||
/// 돌려주는 값: 핀이 가리키는 `id` 가 빈 [QuestPlace](좌표 · 주소).
|
||
class PlacePickerScreen extends StatefulWidget {
|
||
const PlacePickerScreen({
|
||
super.key,
|
||
this.mapBuilder = _googleMap,
|
||
this.lastKnown = _deviceLastKnown,
|
||
this.useDeviceLocation = true,
|
||
});
|
||
|
||
/// 기기 위치(마지막 위치 · 현재 위치)를 쓸지. 위치기반 서비스 약관에 동의하지 않았으면 false 다 —
|
||
/// 지도에서 직접 고르는 것은 그대로 된다(Q-E1 · R02 「거부해도 일반 기능 이용 가능」).
|
||
final bool useDeviceLocation;
|
||
|
||
/// 지도 위젯 — 위젯 테스트는 플랫폼 뷰가 없어 가짜로 바꾼다.
|
||
final PickerMapBuilder mapBuilder;
|
||
|
||
/// 마지막으로 알려진 단말 위치(없으면 null) — 테스트는 플랫폼 채널이 없어 바꾼다.
|
||
final Future<LatLng?> Function() lastKnown;
|
||
|
||
/// 어드민에 등록될 장소의 표본(좌표 없음). **정책값이 아니다.**
|
||
static const defaultPlaces = [
|
||
QuestPlace(id: 'hongdae', name: '홍대 팝업스토어'),
|
||
QuestPlace(id: 'hybe', name: 'HYBE 사옥'),
|
||
QuestPlace(id: 'coex', name: 'SM 코엑스 아티움'),
|
||
QuestPlace(id: 'kspo', name: '올림픽공원 체조경기장'),
|
||
QuestPlace(id: 'gocheok', name: '고척 스카이돔'),
|
||
];
|
||
|
||
/// 현재 · 마지막 위치를 얻지 못했을 때 지도를 처음 비추는 곳(서울시청). 화면 위치일 뿐 판정값이 아니다.
|
||
static const fallbackCenter = LatLng(37.5665, 126.9780);
|
||
|
||
@override
|
||
State<PlacePickerScreen> createState() => _PlacePickerScreenState();
|
||
}
|
||
|
||
class _PlacePickerScreenState extends State<PlacePickerScreen> {
|
||
GoogleMapController? _map;
|
||
|
||
/// 지도 가운데 좌표(마지막으로 안 값).
|
||
LatLng? _camera;
|
||
int _lookups = 0;
|
||
QuestPlace? _selected;
|
||
|
||
/// 지도를 처음 비추는 곳 — 마지막으로 알려진 위치를 읽기 전에는 지도를 만들지 않는다(null).
|
||
/// 현재 위치가 확정되기 전에 엉뚱한 곳이 비치지 않게 한다.
|
||
LatLng? _start;
|
||
|
||
/// 가운데 좌표의 주소를 찾는 중 — 이 동안은 *선택 완료* 를 잠근다(명세 「정상 호출 후 활성화」).
|
||
bool _resolving = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
if (!widget.useDeviceLocation) {
|
||
_start = PlacePickerScreen.fallbackCenter;
|
||
return;
|
||
}
|
||
_startFromLastKnown();
|
||
_locate();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_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);
|
||
_moveTo(here);
|
||
await _drop(here);
|
||
} catch (_) {
|
||
// 권한 거부 · 위치 꺼짐 · 플랫폼 구현 없음(테스트) — 기본 화면 유지.
|
||
}
|
||
}
|
||
|
||
void _moveTo(LatLng point) {
|
||
_camera = point;
|
||
_map?.moveCamera(CameraUpdate.newLatLng(point));
|
||
}
|
||
|
||
/// 사용자가 지도를 움직이기 시작하면 주소 확인 전까지 *선택 완료* 를 잠근다.
|
||
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);
|
||
}
|
||
|
||
/// 가운데 좌표의 주소 — 단말 지오코더로 찾고, 못 찾으면 좌표를 그대로 보인다.
|
||
Future<void> _drop(LatLng point) async {
|
||
final coords =
|
||
'${point.latitude.toStringAsFixed(5)}, ${point.longitude.toStringAsFixed(5)}';
|
||
setState(() {
|
||
_resolving = true;
|
||
_selected = QuestPlace(
|
||
id: '',
|
||
name: coords,
|
||
lat: point.latitude,
|
||
lng: point.longitude,
|
||
);
|
||
});
|
||
var address = coords;
|
||
_lookups++;
|
||
try {
|
||
final marks = await Geocoding().placemarkFromCoordinates(
|
||
point.latitude,
|
||
point.longitude,
|
||
);
|
||
if (marks.isNotEmpty) {
|
||
final line = placeAddress(marks.first);
|
||
if (line.isNotEmpty) address = line;
|
||
}
|
||
} catch (_) {
|
||
// 지오코더가 없거나(에뮬레이터 · 테스트 — 플랫폼 구현 없음) 실패 — 좌표 표시로 둔다.
|
||
}
|
||
_lookups--;
|
||
if (!mounted || _selected?.lat != point.latitude) return;
|
||
setState(() {
|
||
_resolving = false;
|
||
_selected = QuestPlace(
|
||
id: '',
|
||
name: address,
|
||
lat: point.latitude,
|
||
lng: point.longitude,
|
||
);
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final pinned = _selected;
|
||
return Scaffold(
|
||
appBar: AppBar(leading: const CloseButton()),
|
||
body: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
// 시안 `371:356705` 머리 — 안내 문구 + 고른 장소 주소.
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 20, 16, 20),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
context.l10n.placePickPrompt,
|
||
style: const TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w600,
|
||
height: 1.4,
|
||
letterSpacing: -0.45,
|
||
color: AppColors.onCosmic,
|
||
),
|
||
),
|
||
const SizedBox(height: 10),
|
||
Text(
|
||
pinned?.name ?? '',
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w400,
|
||
height: 1.4,
|
||
color: Color(0xFFCDCDCD),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
// 시안 `371:356705` — 지도가 남은 높이를 채우고 「선택 완료」가 그 위에 얹힌다.
|
||
Expanded(
|
||
child: Stack(
|
||
children: [
|
||
if (_start != null)
|
||
widget.mapBuilder(
|
||
PickerMapArgs(
|
||
initial: _start!,
|
||
onCreated: (c) => _map = c,
|
||
onMoveStarted: _moveStarted,
|
||
onMove: (p) => _camera = p,
|
||
onIdle: _idle,
|
||
),
|
||
),
|
||
// 위치 마크 — *지도 영역 중간 정렬*. 핀 끝이 지도 가운데(선택 좌표)를 가리킨다.
|
||
const IgnorePointer(
|
||
child: Align(
|
||
child: Padding(
|
||
padding: EdgeInsets.only(bottom: 44),
|
||
child: SizedBox(
|
||
width: 38,
|
||
height: 44,
|
||
child: CustomPaint(painter: _PinPainter()),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
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),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 지도 위젯이 받는 값 — [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 는 「도로명 번지」만 준다.
|
||
///
|
||
/// 지오코더가 지번 줄(「마포구 서교동 353-5」)만 주면 지번 그대로 둔다 — Figma 퀘스트 생성 「위치 선택」
|
||
/// 예시가 지번이다(`548:19930` · `372:357949` 등 「서울특별시 동작구 동작동 산22-11」). 도로명으로 바꾸는
|
||
/// 역지오코딩은 기준자료에 없다(2026-10-06 재확인).
|
||
String placeAddress(Placemark m) {
|
||
String t(String? v) => v?.trim() ?? '';
|
||
final street = t(m.street);
|
||
final area = t(m.administrativeArea);
|
||
if (street.isNotEmpty && area.isNotEmpty && street.contains(area)) {
|
||
// 전체 줄 — 앞의 나라 이름만 뗀다.
|
||
final country = t(m.country);
|
||
return country.isNotEmpty && street.startsWith(country)
|
||
? street.substring(country.length).trim()
|
||
: street;
|
||
}
|
||
final road = street.isNotEmpty
|
||
? street
|
||
: [
|
||
t(m.thoroughfare),
|
||
t(m.subThoroughfare),
|
||
].where((s) => s.isNotEmpty).join(' ');
|
||
return [
|
||
area,
|
||
t(m.locality),
|
||
t(m.subLocality),
|
||
road,
|
||
].where((s) => s.isNotEmpty).toSet().join(' ');
|
||
}
|
||
|
||
/// Figma `372:356854` 핀 — 38×44 분홍(#FF7DDD) 물방울 · 흰 테두리 · 가운데 흰 점 12.
|
||
class _PinPainter extends CustomPainter {
|
||
const _PinPainter();
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final r = size.width / 2;
|
||
final drop = ui.Path.combine(
|
||
PathOperation.union,
|
||
ui.Path()..addOval(Rect.fromCircle(center: Offset(r, r), radius: r)),
|
||
ui.Path()
|
||
..moveTo(r * 0.16, r * 1.5)
|
||
..lineTo(size.width - r * 0.16, r * 1.5)
|
||
..lineTo(r, size.height)
|
||
..close(),
|
||
);
|
||
canvas
|
||
..drawPath(drop, Paint()..color = AppColors.cosmicAccent)
|
||
..drawPath(
|
||
drop,
|
||
Paint()
|
||
..color = Colors.white
|
||
..style = PaintingStyle.stroke
|
||
..strokeWidth = 1.5,
|
||
)
|
||
..drawCircle(Offset(r, r), 6, Paint()..color = Colors.white);
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||
}
|