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, }); /// 지도 위젯 — 위젯 테스트는 플랫폼 뷰가 없어 가짜로 바꾼다. final PickerMapBuilder mapBuilder; /// 마지막으로 알려진 단말 위치(없으면 null) — 테스트는 플랫폼 채널이 없어 바꾼다. final Future 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 createState() => _PlacePickerScreenState(); } class _PlacePickerScreenState extends State { GoogleMapController? _map; /// 지도 가운데 좌표(마지막으로 안 값). LatLng? _camera; int _lookups = 0; QuestPlace? _selected; /// 지도를 처음 비추는 곳 — 마지막으로 알려진 위치를 읽기 전에는 지도를 만들지 않는다(null). /// 현재 위치가 확정되기 전에 엉뚱한 곳이 비치지 않게 한다. LatLng? _start; /// 가운데 좌표의 주소를 찾는 중 — 이 동안은 *선택 완료* 를 잠근다(명세 「정상 호출 후 활성화」). bool _resolving = false; @override void initState() { super.initState(); _startFromLastKnown(); _locate(); } @override void dispose() { _map?.dispose(); super.dispose(); } /// 마지막으로 알려진 위치(있으면)에서 시작한다. 없거나 못 읽으면 [PlacePickerScreen.fallbackCenter]. Future _startFromLastKnown() async { var start = PlacePickerScreen.fallbackCenter; try { start = await widget.lastKnown() ?? start; } catch (_) { // 플랫폼 구현 없음(테스트) · 미지원 — 기본 위치로 시작. } if (!mounted) return; // 현재 위치가 먼저 와 카메라가 옮겨졌다면 그 자리에서 시작한다. setState(() => _start = _camera ?? start); } /// 디폴트 사용자 현재 위치. 권한이 없거나 꺼져 있으면 지금 화면(마지막 위치 · 서울시청)에 둔다 — /// 탐색은 권한 없이 열린다(B-27). 이때는 사용자가 지도를 움직여야 위치가 정해진다. Future _locate() async { try { final fix = await devicePlaceFix(); if (!mounted || _selected != null) return; final here = LatLng(fix.lat, fix.lng); _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 _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 onCreated; final VoidCallback onMoveStarted; final ValueChanged onMove; final VoidCallback onIdle; } typedef PickerMapBuilder = Widget Function(PickerMapArgs args); Future _deviceLastKnown() async { final p = await Geolocator.getLastKnownPosition(); return p == null ? null : LatLng(p.latitude, p.longitude); } /// Figma `371:356705` 어두운 지도 스타일(JSON 에셋). 한 번 읽어 둔다. final Future _mapStyle = rootBundle.loadString('assets/map/dark_style.json'); Widget _googleMap(PickerMapArgs a) => FutureBuilder( future: _mapStyle, builder: (context, style) => GoogleMap( initialCameraPosition: CameraPosition(target: a.initial, zoom: 15), style: style.data, onMapCreated: a.onCreated, onCameraMoveStarted: a.onMoveStarted, onCameraMove: (c) => a.onMove(c.target), onCameraIdle: a.onIdle, // 핀은 화면 가운데에 고정이라 기본 버튼은 쓰지 않는다. zoomControlsEnabled: false, myLocationButtonEnabled: false, mapToolbarEnabled: false, // 아래 목록 스크롤과 겹치지 않게 지도 안의 제스처는 지도가 가진다. gestureRecognizers: { Factory(EagerGestureRecognizer.new), }, ), ); /// 지오코더 결과 → 한 줄 주소. 한국 주소는 도로명이 `thoroughfare` 에 오지 않아(번지만 `subThoroughfare`) /// 칸을 이어 붙이면 「서울특별시 마포구 34」가 된다(10/6 실기기). 도로명이 든 `street` 를 쓴다 — /// Android 는 주소 전체 줄(나라 이름 포함), iOS 는 「도로명 번지」만 준다. /// /// 지오코더가 지번 줄(「마포구 서교동 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; }