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

233 lines
9.0 KiB
Dart

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 m = marks.first;
final parts = [m.administrativeArea, m.locality, m.subLocality, m.thoroughfare, m.subThoroughfare]
.whereType<String>()
.where((s) => s.isNotEmpty)
.toList();
if (parts.isNotEmpty) address = parts.join(' ');
}
} 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',
),
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!),
alignment: Alignment.topCenter,
child: const Icon(Icons.location_pin, size: 40, color: Colors.redAccent),
),
],
),
// OSM 타일 이용 조건 — 출처 표기.
const Align(
alignment: Alignment.bottomRight,
child: Text(
'© OpenStreetMap',
style: TextStyle(fontSize: 10, color: Colors.black54),
),
),
],
),
),
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),
),
),
),
],
),
);
}
}