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

390 lines
14 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:ui' as ui;
import 'package:flutter/foundation.dart' show Factory;
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:geocoding/geocoding.dart';
import 'package:geolocator/geolocator.dart' show Geolocator;
import 'package:google_maps_flutter/google_maps_flutter.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` — 지도는 *디폴트 사용자 현재 위치*, 위치 마크는 *지도 영역 중간 정렬*, 지도를
/// 움직이면 가운데 좌표의 주소를 *실시간* 으로 보이고, *선택 완료* 는 위치 정보를 정상 호출한 뒤 켜진다.
/// 장소 검색 · 등록 장소 목록 · 등록 장소 핀은 Figma 와 설명 프레임 어디에도 없어 두지 않는다
/// (해석 확인 요청 영역 B — Q-B-03 장소 등록 주체). 보상 인증 반경은 서버가 B-27(기본 100m)로 판정한다.
///
/// 돌려주는 값: 핀이 가리키는 `id` 가 빈 [QuestPlace](좌표 · 주소).
class PlacePickerScreen extends StatefulWidget {
const PlacePickerScreen({
super.key,
this.mapBuilder = _googleMap,
this.lastKnown = _deviceLastKnown,
this.useDeviceLocation = true,
});
/// 기기 위치(마지막 위치 · 현재 위치)를 쓸지. 위치기반 서비스 약관에 동의하지 않았으면 false 다 —
/// 지도에서 직접 고르는 것은 그대로 된다(Q-E1 · R02 「거부해도 일반 기능 이용 가능」).
final bool useDeviceLocation;
/// 지도 위젯 — 위젯 테스트는 플랫폼 뷰가 없어 가짜로 바꾼다.
final PickerMapBuilder mapBuilder;
/// 마지막으로 알려진 단말 위치(없으면 null) — 테스트는 플랫폼 채널이 없어 바꾼다.
final Future<LatLng?> Function() lastKnown;
/// 어드민에 등록될 장소의 표본(좌표 없음). **정책값이 아니다.**
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> {
GoogleMapController? _map;
/// 지도 가운데 좌표(마지막으로 안 값).
LatLng? _camera;
int _lookups = 0;
QuestPlace? _selected;
/// 지도를 처음 비추는 곳 — 마지막으로 알려진 위치를 읽기 전에는 지도를 만들지 않는다(null).
/// 현재 위치가 확정되기 전에 엉뚱한 곳이 비치지 않게 한다.
LatLng? _start;
/// 가운데 좌표의 주소를 찾는 중 — 이 동안은 *선택 완료* 를 잠근다(명세 「정상 호출 후 활성화」).
bool _resolving = false;
@override
void initState() {
super.initState();
if (!widget.useDeviceLocation) {
_start = PlacePickerScreen.fallbackCenter;
return;
}
_startFromLastKnown();
_locate();
}
@override
void dispose() {
_map?.dispose();
super.dispose();
}
/// 마지막으로 알려진 위치(있으면)에서 시작한다. 없거나 못 읽으면 [PlacePickerScreen.fallbackCenter].
Future<void> _startFromLastKnown() async {
var start = PlacePickerScreen.fallbackCenter;
try {
start = await widget.lastKnown() ?? start;
} catch (_) {
// 플랫폼 구현 없음(테스트) · 미지원 — 기본 위치로 시작.
}
if (!mounted) return;
// 현재 위치가 먼저 와 카메라가 옮겨졌다면 그 자리에서 시작한다.
setState(() => _start = _camera ?? start);
}
/// 디폴트 사용자 현재 위치. 권한이 없거나 꺼져 있으면 지금 화면(마지막 위치 · 서울시청)에 둔다 —
/// 탐색은 권한 없이 열린다(B-27). 이때는 사용자가 지도를 움직여야 위치가 정해진다.
Future<void> _locate() async {
try {
final fix = await devicePlaceFix();
if (!mounted || _selected != null) return;
final here = LatLng(fix.lat, fix.lng);
_moveTo(here);
await _drop(here);
} catch (_) {
// 권한 거부 · 위치 꺼짐 · 플랫폼 구현 없음(테스트) — 기본 화면 유지.
}
}
void _moveTo(LatLng point) {
_camera = point;
_map?.moveCamera(CameraUpdate.newLatLng(point));
}
/// 사용자가 지도를 움직이기 시작하면 주소 확인 전까지 *선택 완료* 를 잠근다.
void _moveStarted() => setState(() => _resolving = true);
/// 지도가 멈추면 가운데 좌표로 위치를 정한다. 코드가 옮긴 멈춤(현재 위치)은 이미 그
/// 좌표가 선택돼 있으므로 다시 찾지 않는다.
void _idle() {
final c = _camera;
if (c == null) return;
final s = _selected;
if (s != null && s.lat == c.latitude && s.lng == c.longitude) {
if (_resolving && _lookups == 0) setState(() => _resolving = false);
return;
}
_drop(c);
}
/// 가운데 좌표의 주소 — 단말 지오코더로 찾고, 못 찾으면 좌표를 그대로 보인다.
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;
_lookups++;
try {
final marks = await Geocoding().placemarkFromCoordinates(
point.latitude,
point.longitude,
);
if (marks.isNotEmpty) {
final line = placeAddress(marks.first);
if (line.isNotEmpty) address = line;
}
} catch (_) {
// 지오코더가 없거나(에뮬레이터 · 테스트 — 플랫폼 구현 없음) 실패 — 좌표 표시로 둔다.
}
_lookups--;
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),
),
),
],
),
),
// 시안 `371:356705` — 지도가 남은 높이를 채우고 「선택 완료」가 그 위에 얹힌다.
Expanded(
child: Stack(
children: [
if (_start != null)
widget.mapBuilder(
PickerMapArgs(
initial: _start!,
onCreated: (c) => _map = c,
onMoveStarted: _moveStarted,
onMove: (p) => _camera = p,
onIdle: _idle,
),
),
// 위치 마크 — *지도 영역 중간 정렬*. 핀 끝이 지도 가운데(선택 좌표)를 가리킨다.
const IgnorePointer(
child: Align(
child: Padding(
padding: EdgeInsets.only(bottom: 44),
child: SizedBox(
width: 38,
height: 44,
child: CustomPaint(painter: _PinPainter()),
),
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: 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),
),
),
),
),
],
),
),
],
),
);
}
}
/// 지도 위젯이 받는 값 — [PlacePickerScreen.mapBuilder].
class PickerMapArgs {
const PickerMapArgs({
required this.initial,
required this.onCreated,
required this.onMoveStarted,
required this.onMove,
required this.onIdle,
});
final LatLng initial;
final ValueChanged<GoogleMapController> onCreated;
final VoidCallback onMoveStarted;
final ValueChanged<LatLng> onMove;
final VoidCallback onIdle;
}
typedef PickerMapBuilder = Widget Function(PickerMapArgs args);
Future<LatLng?> _deviceLastKnown() async {
final p = await Geolocator.getLastKnownPosition();
return p == null ? null : LatLng(p.latitude, p.longitude);
}
/// Figma `371:356705` 어두운 지도 스타일(JSON 에셋). 한 번 읽어 둔다.
final Future<String> _mapStyle = rootBundle.loadString('assets/map/dark_style.json');
Widget _googleMap(PickerMapArgs a) => FutureBuilder<String>(
future: _mapStyle,
builder: (context, style) => GoogleMap(
initialCameraPosition: CameraPosition(target: a.initial, zoom: 15),
style: style.data,
onMapCreated: a.onCreated,
onCameraMoveStarted: a.onMoveStarted,
onCameraMove: (c) => a.onMove(c.target),
onCameraIdle: a.onIdle,
// 핀은 화면 가운데에 고정이라 기본 버튼은 쓰지 않는다.
zoomControlsEnabled: false,
myLocationButtonEnabled: false,
mapToolbarEnabled: false,
// 아래 목록 스크롤과 겹치지 않게 지도 안의 제스처는 지도가 가진다.
gestureRecognizers: {
Factory<OneSequenceGestureRecognizer>(EagerGestureRecognizer.new),
},
),
);
/// 지오코더 결과 → 한 줄 주소. 한국 주소는 도로명이 `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;
}