316 lines
11 KiB
Dart
316 lines
11 KiB
Dart
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<QuestPlace> 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<PlacePickerScreen> createState() => _PlacePickerScreenState();
|
||
}
|
||
|
||
class _PlacePickerScreenState extends State<PlacePickerScreen> {
|
||
final _map = MapController();
|
||
String _query = '';
|
||
QuestPlace? _selected;
|
||
|
||
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;
|
||
}
|
||
|
||
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<void> _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<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
|
||
? 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;
|
||
}
|