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'; /// 위치 선택 — 화면 대조 원장 F4 · Figma `371:356705`(지도 + 핀 주소 + 「선택 완료」). /// /// 지도를 누르면 그 자리에 핀을 꽂고 주소를 보여 준다. 운영이 등록한 장소는 지도 위 표시와 /// 아래 목록으로 함께 보이고, 고르면 그 장소를 쓴다(해석 확인 요청 영역 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; 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; } void _choose(QuestPlace place) { setState(() => _selected = place); if (place.lat != null && place.lng != null) { _map.move(LatLng(place.lat!, place.lng!), _map.camera.zoom); } } /// 지도를 누른 자리에 핀 — 주소는 단말 지오코더로 찾고, 못 찾으면 좌표를 그대로 보인다. Future _drop(LatLng point) async { final coords = '${point.latitude.toStringAsFixed(5)}, ${point.longitude.toStringAsFixed(5)}'; setState( () => _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( () => _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, onTap: (_, point) => _drop(point), ), 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, ), ), ), if (pinned != null && pinned.lat != null && pinned.lng != null) Marker( point: LatLng(pinned.lat!, pinned.lng!), width: 38, height: 44, alignment: Alignment.topCenter, child: const 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 ? 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 는 「도로명 번지」만 준다. 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; }