From dda122baf04f84cb267ab4069a7dc6f42f528b3e Mon Sep 17 00:00:00 2001 From: ttipo Date: Thu, 5 Nov 2026 13:38:11 +0900 Subject: [PATCH] =?UTF-8?q?fix(flutter):=20=EB=8C=80=EA=B5=AC=20=EB=B0=96?= =?UTF-8?q?=20=ED=98=84=EC=9E=AC=20=EC=9C=84=EC=B9=98=EB=8A=94=20=EB=8C=80?= =?UTF-8?q?=EA=B5=AC=20=EB=8F=84=EC=8B=AC=EC=9C=BC=EB=A1=9C=20=EB=8C=80?= =?UTF-8?q?=EC=8B=A0=ED=95=98=EA=B3=A0=20=EC=95=88=EB=82=B4=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 현재 위치를 그대로 쓰면 대구 밖(서울 등)에서는 모든 칸이 "근처에 아직 없어요"로 비었다(2026-11-04 회귀 점검). 서비스 지역(대구, 군위 포함 — 백엔드 geo.ts 와 같은 영역) 밖이면 대구 도심을 기준으로 쓰고, 홈에서 "아직 대구 지역만 볼 수 있어요"를 한 번 알린다. 위치를 받지 못해 대신하는 경우도 지금까지는 기준만 조용히 바뀌었는데, 같은 방식으로 알린다. 대신한 이유는 BasePoint.fallback 에 남는다. Co-Authored-By: Claude Opus 5.5 --- flutter/README.md | 1 + .../home/presentation/pages/home_page.dart | 14 +++++++ .../location/domain/base_location.dart | 34 +++++++++++++---- .../providers/base_location_provider.dart | 12 ++++-- flutter/lib/l10n/app_en.arb | 1 + flutter/lib/l10n/app_ko.arb | 1 + flutter/test/features/base_location_test.dart | 20 ++++++++++ flutter/test/widget_test.dart | 38 +++++++++++++++++-- 8 files changed, 107 insertions(+), 14 deletions(-) create mode 100644 flutter/test/features/base_location_test.dart diff --git a/flutter/README.md b/flutter/README.md index 2d6b597..1b1949e 100644 --- a/flutter/README.md +++ b/flutter/README.md @@ -65,4 +65,5 @@ lib/ (서버가 `trusted=true` 로 거른다 — 앱에서 거르면 상가가 빽빽한 도심에서 칸이 비었다). - `Semantics(excludeSemantics: true)` 로 버튼을 감싸면 안쪽 InkWell 의 누르기 동작까지 지워진다. Semantics 에도 `onTap` 을 같이 준다 (빠뜨리면 TalkBack·VoiceOver 로 누를 수 없다 — `widget_test.dart` 의 화면 읽기 테스트가 잡는다). +- 현재 위치를 받지 못하거나 서비스 지역(대구, `kServiceArea`) 밖이면 대구 도심을 기준으로 쓰고 홈에서 이유를 한 번 알린다(`BasePoint.fallback`). - 실패 문구는 에러 사전(`error_lexicon.g.dart`)에서만 가져온다 — `errorMessageOf()`. diff --git a/flutter/lib/features/home/presentation/pages/home_page.dart b/flutter/lib/features/home/presentation/pages/home_page.dart index f7c9250..c56552d 100644 --- a/flutter/lib/features/home/presentation/pages/home_page.dart +++ b/flutter/lib/features/home/presentation/pages/home_page.dart @@ -44,6 +44,20 @@ class HomePage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final l10n = context.l10n; final textTheme = Theme.of(context).textTheme; + // 현재 위치 대신 대구 도심을 쓰게 되면 이유를 한 번 알린다(같은 이유로 다시 받을 때는 띄우지 않는다) + ref.listen(basePointProvider.select((base) => base.value?.fallback), ( + previous, + next, + ) { + if (next == null || next == previous) return; + final message = switch (next) { + BaseFallback.unavailable => l10n.locationUnavailable, + BaseFallback.outsideServiceArea => l10n.locationOutsideServiceArea, + }; + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + }); return Scaffold( body: SafeArea( bottom: false, diff --git a/flutter/lib/features/location/domain/base_location.dart b/flutter/lib/features/location/domain/base_location.dart index 27b09fa..811abcb 100644 --- a/flutter/lib/features/location/domain/base_location.dart +++ b/flutter/lib/features/location/domain/base_location.dart @@ -55,17 +55,37 @@ class BaseLocationChoice { int get hashCode => neighborhood.hashCode; } +/// 서비스 지역(1차 출시: 대구) 대략 영역 — 2023년 편입한 군위군 포함. +/// 백엔드 `shared/utils/geo.ts` 의 시·도 영역(27 대구)과 같은 값이다. +const ({double latMin, double latMax, double lngMin, double lngMax}) +kServiceArea = (latMin: 35.55, latMax: 36.36, lngMin: 128.3, lngMax: 128.9); + +/// 좌표가 서비스 지역 안인지 본다. 밖에서는 볼 장소·행사가 거의 없다 +bool isInServiceArea(GeoPoint p) => + p.lat >= kServiceArea.latMin && + p.lat <= kServiceArea.latMax && + p.lng >= kServiceArea.lngMin && + p.lng <= kServiceArea.lngMax; + +/// 현재 위치 대신 [kFallbackNeighborhood] 를 쓴 이유 +enum BaseFallback { + /// 위치를 받지 못했다(권한 없음·위치 서비스 꺼짐·시간 초과) + unavailable, + + /// 서비스 지역(대구) 밖이다 — 그대로 쓰면 모든 칸이 "근처에 아직 없어요"로 빈다 + outsideServiceArea, +} + /// 실제로 조회에 쓰는 기준 좌표 class BasePoint { - const BasePoint({ - required this.point, - required this.choice, - this.fellBack = false, - }); + const BasePoint({required this.point, required this.choice, this.fallback}); final GeoPoint point; final BaseLocationChoice choice; - /// 현재 위치를 고르고도 위치를 못 받아 [kFallbackNeighborhood] 를 쓴 경우 - final bool fellBack; + /// 현재 위치를 고르고도 [kFallbackNeighborhood] 를 쓴 이유. 대신하지 않았으면 null + final BaseFallback? fallback; + + /// 현재 위치를 고르고도 [kFallbackNeighborhood] 를 쓴 경우 + bool get fellBack => fallback != null; } diff --git a/flutter/lib/features/location/presentation/providers/base_location_provider.dart b/flutter/lib/features/location/presentation/providers/base_location_provider.dart index 26f89fa..05c2f4b 100644 --- a/flutter/lib/features/location/presentation/providers/base_location_provider.dart +++ b/flutter/lib/features/location/presentation/providers/base_location_provider.dart @@ -28,7 +28,8 @@ class BaseLocationNotifier extends Notifier { } } -/// 조회에 쓰는 기준 좌표 — 현재 위치를 골랐는데 받지 못하면 [kFallbackNeighborhood] 로 대신한다 +/// 조회에 쓰는 기준 좌표 — 현재 위치를 골랐는데 받지 못하거나 서비스 지역(대구) 밖이면 +/// [kFallbackNeighborhood] 로 대신하고 이유를 남긴다(홈이 안내한다) final basePointProvider = FutureProvider((ref) async { final choice = ref.watch(baseLocationProvider); final neighborhood = choice.neighborhood; @@ -36,10 +37,15 @@ final basePointProvider = FutureProvider((ref) async { return BasePoint(point: neighborhood.point, choice: choice); } final current = await ref.watch(locationServiceProvider).currentPoint(); - if (current != null) return BasePoint(point: current, choice: choice); + final BaseFallback? fallback = current == null + ? BaseFallback.unavailable + : (isInServiceArea(current) ? null : BaseFallback.outsideServiceArea); + if (current != null && fallback == null) { + return BasePoint(point: current, choice: choice); + } return BasePoint( point: kFallbackNeighborhood.point, choice: choice, - fellBack: true, + fallback: fallback, ); }); diff --git a/flutter/lib/l10n/app_en.arb b/flutter/lib/l10n/app_en.arb index 1010cb0..6624cdf 100644 --- a/flutter/lib/l10n/app_en.arb +++ b/flutter/lib/l10n/app_en.arb @@ -24,6 +24,7 @@ "regionApsan": "Apsan Cafe Street", "regionApsanHint": "Daemyeong-dong, Nam-gu", "locationUnavailable": "We couldn't get your location, so we're showing downtown Daegu.", + "locationOutsideServiceArea": "WanderGo only covers Daegu for now, so we're showing downtown Daegu.", "baseLocationButtonLabel": "Change base location, current {place}", "homeTitle": "Where to this weekend?", "homeWeekendEvents": "Festivals & events this weekend", diff --git a/flutter/lib/l10n/app_ko.arb b/flutter/lib/l10n/app_ko.arb index afc69cd..412d98b 100644 --- a/flutter/lib/l10n/app_ko.arb +++ b/flutter/lib/l10n/app_ko.arb @@ -35,6 +35,7 @@ "regionApsan": "앞산 카페거리", "regionApsanHint": "남구 대명동", "locationUnavailable": "현재 위치를 가져오지 못해 대구 도심을 기준으로 보여 드려요.", + "locationOutsideServiceArea": "아직 대구 지역만 볼 수 있어요. 대구 도심을 기준으로 보여 드려요.", "baseLocationButtonLabel": "기준 위치 변경, 현재 {place}", "@baseLocationButtonLabel": { "placeholders": { diff --git a/flutter/test/features/base_location_test.dart b/flutter/test/features/base_location_test.dart new file mode 100644 index 0000000..78e0cc5 --- /dev/null +++ b/flutter/test/features/base_location_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:wandergo/features/location/domain/base_location.dart'; + +void main() { + group('isInServiceArea', () { + test('대구 동네·군위군·경산 경계는 서비스 지역 안이다', () { + for (final n in Neighborhood.values) { + expect(isInServiceArea(n.point), isTrue, reason: n.name); + } + expect(isInServiceArea((lat: 36.2427, lng: 128.5728)), isTrue); // 군위군청 + expect(isInServiceArea((lat: 35.8251, lng: 128.7415)), isTrue); // 경산시청 + }); + + test('서울·부산·포항은 서비스 지역 밖이다', () { + expect(isInServiceArea((lat: 37.5663, lng: 126.9779)), isFalse); + expect(isInServiceArea((lat: 35.1796, lng: 129.0756)), isFalse); + expect(isInServiceArea((lat: 36.0190, lng: 129.3435)), isFalse); + }); + }); +} diff --git a/flutter/test/widget_test.dart b/flutter/test/widget_test.dart index d79e57c..e535b90 100644 --- a/flutter/test/widget_test.dart +++ b/flutter/test/widget_test.dart @@ -179,16 +179,21 @@ class _FakePlaceRepository implements PlaceRepository { } class _FakeLocationService implements LocationService { - const _FakeLocationService({this.granted = true}); + const _FakeLocationService({ + this.granted = true, + this.point = (lat: 35.85, lng: 128.6), + }); final bool granted; + /// 권한이 있을 때 돌려줄 현재 위치(기본은 대구 수성구) + final GeoPoint point; + @override Future requestPermission() async => granted; @override - Future currentPoint() async => - granted ? (lat: 35.85, lng: 128.6) : null; + Future currentPoint() async => granted ? point : null; } Future<_FakeEventRepository> _pumpApp( @@ -197,6 +202,7 @@ Future<_FakeEventRepository> _pumpApp( _FakeEventRepository? repo, _FakePlaceRepository? places, bool locationGranted = true, + GeoPoint currentPoint = (lat: 35.85, lng: 128.6), }) async { SharedPreferences.setMockInitialValues({'app_locale': 'ko', ...prefs}); final preferences = await SharedPreferences.getInstance(); @@ -210,7 +216,7 @@ Future<_FakeEventRepository> _pumpApp( places ?? _FakePlaceRepository(), ), locationServiceProvider.overrideWithValue( - _FakeLocationService(granted: locationGranted), + _FakeLocationService(granted: locationGranted, point: currentPoint), ), clockProvider.overrideWithValue(() => _now), ], @@ -475,4 +481,28 @@ void main() { ); semantics.dispose(); }); + + // 2026-11-04 회귀 점검: 대구 밖 좌표를 그대로 써서 모든 칸이 "근처에 아직 없어요"로 비었다 + testWidgets('대구 밖에서 현재 위치를 고르면 대구 도심을 기준으로 보이고 안내한다', (tester) async { + final events = await _pumpApp( + tester, + prefs: {'onboarding_done': true, 'base_location': 'current'}, + currentPoint: (lat: 37.5663, lng: 126.9779), // 서울시청 + ); + + expect(find.text('아직 대구 지역만 볼 수 있어요. 대구 도심을 기준으로 보여 드려요.'), findsOneWidget); + expect(find.text('대구 도심'), findsOneWidget); + expect(events.calls.last.near, Neighborhood.downtown.point); + }); + + testWidgets('현재 위치를 받지 못하면 대구 도심을 기준으로 보이고 안내한다', (tester) async { + final events = await _pumpApp( + tester, + prefs: {'onboarding_done': true, 'base_location': 'current'}, + locationGranted: false, + ); + + expect(find.text('현재 위치를 가져오지 못해 대구 도심을 기준으로 보여 드려요.'), findsOneWidget); + expect(events.calls.last.near, Neighborhood.downtown.point); + }); } -- 2.52.0