Files
fanuzu/app/lib/features/quest/place_picker_screen.dart
T

372 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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;
/// 가운데 좌표의 주소를 찾는 중 — 이 동안은 *선택 완료* 를 잠근다(명세 「정상 호출 후 활성화」).
bool _resolving = false;
Timer? _settle;
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;
}
@override
void initState() {
super.initState();
_locate();
}
@override
void dispose() {
_settle?.cancel();
super.dispose();
}
/// 디폴트 사용자 현재 위치. 권한이 없거나 꺼져 있으면 지금 화면(등록 장소 · 서울시청)에 둔다 —
/// 탐색은 권한 없이 열린다(B-27). 이때는 사용자가 지도를 움직여야 위치가 정해진다.
Future<void> _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<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;
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<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 || _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;
}