위치 선택 지도를 구글 지도로 · Figma 구성 맞춤

This commit is contained in:
Sn-Kinos
2026-10-06 10:35:20 +09:00
parent 5269b987e0
commit 4e8d989f29
18 changed files with 401 additions and 244 deletions
+151 -142
View File
@@ -1,10 +1,12 @@
import 'dart:async';
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_map/flutter_map.dart';
import 'package:flutter/services.dart' show rootBundle;
import 'package:geocoding/geocoding.dart';
import 'package:latlong2/latlong.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';
@@ -15,15 +17,22 @@ import 'quest_evidence.dart';
///
/// 명세 `523:18207` — 지도는 *디폴트 사용자 현재 위치*, 위치 마크는 *지도 영역 중간 정렬*, 지도를
/// 움직이면 가운데 좌표의 주소를 *실시간* 으로 보이고, *선택 완료* 는 위치 정보를 정상 호출한 뒤 켜진다.
/// 운영이 등록한 장소는 지도 위 표시와
/// 아래 목록으로 함께 보이고, 고르면 그 장소를 쓴다(해석 확인 요청 영역 B — 장소 등록 주체 권고안:
/// 사용자 핀 → 장소 생성 · 운영 장소 우선 노출). 보상 인증 반경은 서버가 B-27(기본 100m)로 판정한다.
/// 장소 검색 · 등록 장소 목록 · 등록 장소 핀은 Figma 와 설명 프레임 어디에도 없어 두지 않는다
/// (해석 확인 요청 영역 B — Q-B-03 장소 등록 주체). 보상 인증 반경은 서버가 B-27(기본 100m)로 판정한다.
///
/// 돌려주는 값: 등록 장소면 그 [QuestPlace], 핀이면 `id` 가 빈 [QuestPlace](좌표 · 주소).
/// 돌려주는 값: 핀이 가리키는 `id` 가 빈 [QuestPlace](좌표 · 주소).
class PlacePickerScreen extends StatefulWidget {
const PlacePickerScreen({super.key, this.places = defaultPlaces});
const PlacePickerScreen({
super.key,
this.mapBuilder = _googleMap,
this.lastKnown = _deviceLastKnown,
});
final List<QuestPlace> places;
/// 지도 위젯 — 위젯 테스트는 플랫폼 뷰가 없어 가짜로 바꾼다.
final PickerMapBuilder mapBuilder;
/// 마지막으로 알려진 단말 위치(없으면 null) — 테스트는 플랫폼 채널이 없어 바꾼다.
final Future<LatLng?> Function() lastKnown;
/// 어드민에 등록될 장소의 표본(좌표 없음). **정책값이 아니다.**
static const defaultPlaces = [
@@ -34,7 +43,7 @@ class PlacePickerScreen extends StatefulWidget {
QuestPlace(id: 'gocheok', name: '고척 스카이돔'),
];
/// 등록 장소 좌표가 하나도 없을 때 지도를 처음 비추는 곳(서울시청). 화면 위치일 뿐 판정값이 아니다.
/// 현재 · 마지막 위치를 얻지 못했을 때 지도를 처음 비추는 곳(서울시청). 화면 위치일 뿐 판정값이 아니다.
static const fallbackCenter = LatLng(37.5665, 126.9780);
@override
@@ -42,69 +51,79 @@ class PlacePickerScreen extends StatefulWidget {
}
class _PlacePickerScreenState extends State<PlacePickerScreen> {
final _map = MapController();
String _query = '';
GoogleMapController? _map;
/// 지도 가운데 좌표(마지막으로 안 값).
LatLng? _camera;
int _lookups = 0;
QuestPlace? _selected;
/// 지도를 처음 비추는 곳 — 마지막으로 알려진 위치를 읽기 전에는 지도를 만들지 않는다(null).
/// 현재 위치가 확정되기 전에 엉뚱한 곳이 비치지 않게 한다.
LatLng? _start;
/// 가운데 좌표의 주소를 찾는 중 — 이 동안은 *선택 완료* 를 잠근다(명세 「정상 호출 후 활성화」).
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();
_startFromLastKnown();
_locate();
}
@override
void dispose() {
_settle?.cancel();
_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);
_map.move(here, _map.camera.zoom);
_moveTo(here);
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 _moveTo(LatLng point) {
_camera = point;
_map?.moveCamera(CameraUpdate.newLatLng(point));
}
/// 사용자가 지도를 움직이면 멈춘 뒤 가운데 좌표로 위치를 정한다(제스처가 아닌 이동 — 등록 장소
/// 선택 · 현재 위치 이동 — 은 무시).
void _moved(MapCamera camera, bool hasGesture) {
if (!hasGesture) return;
_settle?.cancel();
setState(() => _resolving = true);
_settle = Timer(const Duration(milliseconds: 400), () => _drop(camera.center));
/// 사용자가 지도를 움직이기 시작하면 주소 확인 전까지 *선택 완료* 를 잠근다.
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);
}
/// 가운데 좌표의 주소 — 단말 지오코더로 찾고, 못 찾으면 좌표를 그대로 보인다.
@@ -121,6 +140,7 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
);
});
var address = coords;
_lookups++;
try {
final marks = await Geocoding().placemarkFromCoordinates(
point.latitude,
@@ -133,6 +153,7 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
} catch (_) {
// 지오코더가 없거나(에뮬레이터 · 테스트 — 플랫폼 구현 없음) 실패 — 좌표 표시로 둔다.
}
_lookups--;
if (!mounted || _selected?.lat != point.latitude) return;
setState(() {
_resolving = false;
@@ -182,38 +203,20 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
],
),
),
SizedBox(
height: 300,
child: FlutterMap(
mapController: _map,
options: MapOptions(
initialCenter: _center,
initialZoom: 15,
onPositionChanged: _moved,
),
// 시안 `371:356705` — 지도가 남은 높이를 채우고 「선택 완료」가 그 위에 얹힌다.
Expanded(
child: Stack(
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 (_start != null)
widget.mapBuilder(
PickerMapArgs(
initial: _start!,
onCreated: (c) => _map = c,
onMoveStarted: _moveStarted,
onMove: (p) => _camera = p,
onIdle: _idle,
),
),
// 위치 마크 — *지도 영역 중간 정렬*. 핀 끝이 지도 가운데(선택 좌표)를 가리킨다.
const IgnorePointer(
child: Align(
@@ -227,85 +230,91 @@ class _PlacePickerScreenState extends State<PlacePickerScreen> {
),
),
),
// OSM 타일 이용 조건 — 출처 표기.
const Align(
alignment: Alignment.bottomRight,
child: Text(
'© OpenStreetMap',
style: TextStyle(fontSize: 10, color: Colors.white54),
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),
),
),
),
),
],
),
),
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),
),
),
),
],
),
);
}
}
/// 지도 위젯이 받는 값 — [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 는 「도로명 번지」만 준다.