import 'dart:async'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:geocoding/geocoding.dart'; import 'package:latlong2/latlong.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` — 지도는 *디폴트 사용자 현재 위치*, 위치 마크는 *지도 영역 중간 정렬*, 지도를 /// 움직이면 가운데 좌표의 주소를 *실시간* 으로 보이고, *선택 완료* 는 위치 정보를 정상 호출한 뒤 켜진다. /// 운영이 등록한 장소는 지도 위 표시와 /// 아래 목록으로 함께 보이고, 고르면 그 장소를 쓴다(해석 확인 요청 영역 B — 장소 등록 주체 권고안: /// 사용자 핀 → 장소 생성 · 운영 장소 우선 노출). 보상 인증 반경은 서버가 B-27(기본 100m)로 판정한다. /// /// 돌려주는 값: 등록 장소면 그 [QuestPlace], 핀이면 `id` 가 빈 [QuestPlace](좌표 · 주소). class PlacePickerScreen extends StatefulWidget { const PlacePickerScreen({super.key, this.places = defaultPlaces}); final List places; /// 어드민에 등록될 장소의 표본(좌표 없음). **정책값이 아니다.** 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 { final _map = MapController(); String _query = ''; QuestPlace? _selected; /// 가운데 좌표의 주소를 찾는 중 — 이 동안은 *선택 완료* 를 잠근다(명세 「정상 호출 후 활성화」). bool _resolving = false; Timer? _settle; List get _visible => widget.places .where((p) => _query.isEmpty || p.name.contains(_query)) .toList(); LatLng get _center { for (final p in widget.places) { if (p.lat != null && p.lng != null) return LatLng(p.lat!, p.lng!); } return PlacePickerScreen.fallbackCenter; } @override void initState() { super.initState(); _locate(); } @override void dispose() { _settle?.cancel(); super.dispose(); } /// 디폴트 사용자 현재 위치. 권한이 없거나 꺼져 있으면 지금 화면(등록 장소 · 서울시청)에 둔다 — /// 탐색은 권한 없이 열린다(B-27). 이때는 사용자가 지도를 움직여야 위치가 정해진다. Future _locate() async { try { final fix = await devicePlaceFix(); if (!mounted || _selected != null) return; final here = LatLng(fix.lat, fix.lng); _map.move(here, _map.camera.zoom); 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 _moved(MapCamera camera, bool hasGesture) { if (!hasGesture) return; _settle?.cancel(); setState(() => _resolving = true); _settle = Timer(const Duration(milliseconds: 400), () => _drop(camera.center)); } /// 가운데 좌표의 주소 — 단말 지오코더로 찾고, 못 찾으면 좌표를 그대로 보인다. 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; try { final marks = await Geocoding().placemarkFromCoordinates( point.latitude, point.longitude, ); if (marks.isNotEmpty) { final line = placeAddress(marks.first); if (line.isNotEmpty) address = line; } } catch (_) { // 지오코더가 없거나(에뮬레이터 · 테스트 — 플랫폼 구현 없음) 실패 — 좌표 표시로 둔다. } 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), ), ), ], ), ), SizedBox( height: 300, child: FlutterMap( mapController: _map, options: MapOptions( initialCenter: _center, initialZoom: 15, onPositionChanged: _moved, ), children: [ TileLayer( urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', userAgentPackageName: 'kr.co.fanuzu.app', // Figma `371:356705` 은 어두운 지도다 — 같은 OSM 타일을 어둡게 칠한다(flutter_map 내장). tileBuilder: darkModeTileBuilder, ), MarkerLayer( markers: [ for (final p in widget.places) if (p.lat != null && p.lng != null) Marker( point: LatLng(p.lat!, p.lng!), child: GestureDetector( onTap: () => _choose(p), child: const Icon( Icons.place, color: AppColors.cosmicAccent, ), ), ), ], ), // 위치 마크 — *지도 영역 중간 정렬*. 핀 끝이 지도 가운데(선택 좌표)를 가리킨다. const IgnorePointer( child: Align( child: Padding( padding: EdgeInsets.only(bottom: 44), child: SizedBox( width: 38, height: 44, child: CustomPaint(painter: _PinPainter()), ), ), ), ), // 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( groupValue: pinned?.id, onChanged: (v) => _choose(widget.places.firstWhere((p) => p.id == v)), child: ListView.builder( itemCount: _visible.length, itemBuilder: (context, i) { final place = _visible[i]; return RadioListTile( value: place.id, title: Text(place.name), secondary: const Icon(Icons.place_outlined), ); }, ), ), ), SafeArea( top: false, child: Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), child: FilledButton( onPressed: pinned == null || _resolving ? null : () => Navigator.of(context).pop(pinned), style: FilledButton.styleFrom( minimumSize: const Size.fromHeight(56), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(AppRadius.sm), ), backgroundColor: AppColors.cosmicAccent, foregroundColor: Colors.black, textStyle: const TextStyle( fontFamily: AppFonts.body, fontSize: 16, fontWeight: FontWeight.w600, height: 24 / 16, ), ), child: Text(context.l10n.commonSelectDone), ), ), ), ], ), ); } } /// 지오코더 결과 → 한 줄 주소. 한국 주소는 도로명이 `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; }